接入运营商提现开票待办闭环

This commit is contained in:
jsowell
2026-07-17 17:48:14 +08:00
parent bc20330f1b
commit 3e299f40f9
14 changed files with 920 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
package com.jsowell.pile.constant;
/**
* 运营商提现开票常量。
*/
public final class MerchantWithdrawInvoiceConstants {
public static final String TASK_TYPE = "MERCHANT_WITHDRAW_INVOICE";
public static final String BUSINESS_TYPE = "CLEARING_WITHDRAW_INVOICE";
public static final String ROUTE_NAME = "financeDetail";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_SUBMITTED = "SUBMITTED";
public static final String STATUS_CLOSED = "CLOSED";
private MerchantWithdrawInvoiceConstants() {
}
}

View File

@@ -0,0 +1,36 @@
package com.jsowell.pile.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.jsowell.common.core.domain.BaseEntity;
import lombok.Data;
import java.util.Date;
/**
* 运营商提现开票信息 clearing_withdraw_invoice。
*/
@Data
public class ClearingWithdrawInvoice extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private String withdrawCode;
private String merchantId;
private String invoiceStatus;
private String invoiceNumber;
private String voucherUrl;
private Long submittedBy;
private String submittedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submittedTime;
private Long closedBy;
private String closedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date closedTime;
private String closeReason;
private String delFlag;
}

View File

@@ -0,0 +1,16 @@
package com.jsowell.pile.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 平台关闭提现开票任务请求。
*/
@Data
public class WithdrawInvoiceCloseRequest {
@NotBlank(message = "关闭原因不能为空")
@Size(max = 500, message = "关闭原因不能超过500个字符")
private String reason;
}

View File

@@ -0,0 +1,24 @@
package com.jsowell.pile.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 提交提现发票信息请求。
*/
@Data
public class WithdrawInvoiceSubmitRequest {
@NotNull(message = "待办任务 ID 不能为空")
private Long todoId;
@NotBlank(message = "发票号码不能为空")
@Size(max = 100, message = "发票号码不能超过100个字符")
private String invoiceNumber;
@NotBlank(message = "发票凭证不能为空")
@Size(max = 1000, message = "发票凭证地址不能超过1000个字符")
private String voucherUrl;
}

View File

@@ -0,0 +1,17 @@
package com.jsowell.pile.mapper;
import com.jsowell.pile.domain.ClearingWithdrawInvoice;
import org.apache.ibatis.annotations.Param;
/**
* 运营商提现开票信息 Mapper。
*/
public interface ClearingWithdrawInvoiceMapper {
ClearingWithdrawInvoice selectByWithdrawCode(@Param("withdrawCode") String withdrawCode);
int insertSelective(ClearingWithdrawInvoice invoice);
int markSubmitted(ClearingWithdrawInvoice invoice);
int markClosed(ClearingWithdrawInvoice invoice);
}

View File

@@ -0,0 +1,18 @@
package com.jsowell.pile.service;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.vo.web.MerchantWithdrawInvoiceVO;
/**
* 运营商提现向平台开票服务。
*/
public interface MerchantWithdrawInvoiceService {
void handleWithdrawSucceeded(String withdrawCode);
MerchantWithdrawInvoiceVO getForAssignee(String withdrawCode, Long todoId, Long userId);
boolean submit(String withdrawCode, WithdrawInvoiceSubmitRequest request,
Long userId, String operatorName);
boolean close(String withdrawCode, String reason, Long operatorId, String operatorName);
}

View File

@@ -0,0 +1,332 @@
package com.jsowell.pile.service.impl;
import com.alibaba.fastjson2.JSON;
import com.jsowell.common.constant.Constants;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.common.util.DateUtils;
import com.jsowell.common.util.StringUtils;
import com.jsowell.pile.constant.MerchantWithdrawInvoiceConstants;
import com.jsowell.pile.domain.ClearingBillInfo;
import com.jsowell.pile.domain.ClearingWithdrawInfo;
import com.jsowell.pile.domain.ClearingWithdrawInvoice;
import com.jsowell.pile.domain.PileMerchantInfo;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.mapper.ClearingWithdrawInfoMapper;
import com.jsowell.pile.mapper.ClearingWithdrawInvoiceMapper;
import com.jsowell.pile.service.ClearingBillInfoService;
import com.jsowell.pile.service.MerchantWithdrawInvoiceService;
import com.jsowell.pile.service.PileMerchantInfoService;
import com.jsowell.pile.vo.web.MerchantWithdrawInvoiceVO;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.service.TodoTaskAssigneeService;
import com.jsowell.system.service.TodoTaskService;
import org.apache.commons.collections4.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 运营商提现向平台开票服务实现。
*/
@Service
public class MerchantWithdrawInvoiceServiceImpl implements MerchantWithdrawInvoiceService {
private static final Logger log = LoggerFactory.getLogger(MerchantWithdrawInvoiceServiceImpl.class);
@Resource
private ClearingWithdrawInfoMapper clearingWithdrawInfoMapper;
@Resource
private ClearingWithdrawInvoiceMapper clearingWithdrawInvoiceMapper;
@Autowired
private ClearingBillInfoService clearingBillInfoService;
@Autowired
private PileMerchantInfoService pileMerchantInfoService;
@Autowired
private TodoTaskAssigneeService todoTaskAssigneeService;
@Autowired
private TodoTaskService todoTaskService;
@Override
@Transactional(rollbackFor = Exception.class)
public void handleWithdrawSucceeded(String withdrawCode) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
ClearingWithdrawInfo withdrawInfo = clearingWithdrawInfoMapper.selectByWithdrawCode(safeWithdrawCode);
if (withdrawInfo == null) {
throw new ServiceException("提现记录不存在,无法创建开票待办");
}
Date now = DateUtils.getNowDate();
if (!Constants.ONE.equals(withdrawInfo.getWithdrawStatus()) || withdrawInfo.getArrivalTime() == null) {
withdrawInfo.setWithdrawStatus(Constants.ONE);
withdrawInfo.setArrivalTime(now);
withdrawInfo.setUpdateTime(now);
clearingWithdrawInfoMapper.updateByPrimaryKeySelective(withdrawInfo);
}
completeClearingBills(safeWithdrawCode);
ClearingWithdrawInvoice invoice = getOrCreateInvoice(withdrawInfo);
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
return;
}
createTodos(withdrawInfo);
}
@Override
public MerchantWithdrawInvoiceVO getForAssignee(String withdrawCode, Long todoId, Long userId) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
validateTodo(safeWithdrawCode, todoId, userId);
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
ClearingWithdrawInfo withdrawInfo = clearingWithdrawInfoMapper.selectByWithdrawCode(safeWithdrawCode);
return buildView(invoice, withdrawInfo);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(String withdrawCode, WithdrawInvoiceSubmitRequest request,
Long userId, String operatorName) {
if (request == null) {
throw new ServiceException("发票提交参数不能为空");
}
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
SysTodoTask todoTask = validateTodo(safeWithdrawCode, request.getTodoId(), userId);
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED.equals(invoice.getInvoiceStatus())) {
return true;
}
if (!TodoTaskConstants.isActiveStatus(todoTask.getTaskStatus())) {
throw new ServiceException("当前待办状态不允许提交发票");
}
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
throw new ServiceException("当前提现开票状态不允许提交");
}
Date now = DateUtils.getNowDate();
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED);
invoice.setInvoiceNumber(normalizeRequired(request.getInvoiceNumber(), "发票号码", 100));
invoice.setVoucherUrl(normalizeRequired(request.getVoucherUrl(), "发票凭证", 1000));
invoice.setSubmittedBy(userId);
invoice.setSubmittedName(normalizeOperator(operatorName, userId));
invoice.setSubmittedTime(now);
invoice.setUpdateBy(invoice.getSubmittedName());
invoice.setUpdateTime(now);
if (clearingWithdrawInvoiceMapper.markSubmitted(invoice) == 0) {
ClearingWithdrawInvoice current = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED.equals(current.getInvoiceStatus())) {
return true;
}
throw new ServiceException("当前提现开票状态不允许提交");
}
todoTaskService.completeByBusiness(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE,
safeWithdrawCode, null, userId, invoice.getSubmittedName());
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean close(String withdrawCode, String reason, Long operatorId, String operatorName) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
if (operatorId == null || operatorId <= 0) {
throw new ServiceException("关闭操作人不能为空");
}
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_CLOSED.equals(invoice.getInvoiceStatus())) {
return true;
}
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
throw new ServiceException("当前提现开票状态不允许关闭");
}
Date now = DateUtils.getNowDate();
String safeOperator = normalizeOperator(operatorName, operatorId);
String safeReason = normalizeRequired(reason, "关闭原因", 500);
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_CLOSED);
invoice.setClosedBy(operatorId);
invoice.setClosedName(safeOperator);
invoice.setClosedTime(now);
invoice.setCloseReason(safeReason);
invoice.setUpdateBy(safeOperator);
invoice.setUpdateTime(now);
if (clearingWithdrawInvoiceMapper.markClosed(invoice) == 0) {
ClearingWithdrawInvoice current = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_CLOSED.equals(current.getInvoiceStatus())) {
return true;
}
throw new ServiceException("当前提现开票状态不允许关闭");
}
todoTaskService.cancelByBusiness(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE,
safeWithdrawCode, null, safeOperator, safeReason);
return true;
}
private void completeClearingBills(String withdrawCode) {
List<ClearingBillInfo> billInfos = clearingBillInfoService.selectByWithdrawCode(withdrawCode);
if (CollectionUtils.isEmpty(billInfos)) {
return;
}
for (ClearingBillInfo billInfo : billInfos) {
billInfo.setBillStatus("4");
}
clearingBillInfoService.updateBatchSelective(billInfos);
}
private ClearingWithdrawInvoice getOrCreateInvoice(ClearingWithdrawInfo withdrawInfo) {
ClearingWithdrawInvoice invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawInfo.getWithdrawCode());
if (invoice != null) {
return invoice;
}
invoice = new ClearingWithdrawInvoice();
invoice.setWithdrawCode(withdrawInfo.getWithdrawCode());
invoice.setMerchantId(withdrawInfo.getMerchantId());
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_PENDING);
invoice.setCreateBy("adapay-callback");
invoice.setDelFlag("0");
try {
clearingWithdrawInvoiceMapper.insertSelective(invoice);
return invoice;
} catch (DuplicateKeyException exception) {
invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawInfo.getWithdrawCode());
if (invoice != null) {
return invoice;
}
throw exception;
}
}
private void createTodos(ClearingWithdrawInfo withdrawInfo) {
if (StringUtils.isBlank(withdrawInfo.getMerchantId())) {
log.warn("提现记录缺少运营商无法创建开票待办withdrawCode={}", withdrawInfo.getWithdrawCode());
return;
}
PileMerchantInfo merchantInfo;
try {
merchantInfo = pileMerchantInfoService.selectPileMerchantInfoById(Long.parseLong(withdrawInfo.getMerchantId()));
} catch (NumberFormatException exception) {
log.warn("提现记录运营商 ID 非法无法创建开票待办withdrawCode={}, merchantId={}",
withdrawInfo.getWithdrawCode(), withdrawInfo.getMerchantId());
return;
}
if (merchantInfo == null || StringUtils.isBlank(merchantInfo.getDeptId())) {
log.warn("提现记录运营商未配置部门无法创建开票待办withdrawCode={}, merchantId={}",
withdrawInfo.getWithdrawCode(), withdrawInfo.getMerchantId());
return;
}
Long deptId;
try {
deptId = Long.parseLong(merchantInfo.getDeptId());
} catch (NumberFormatException exception) {
log.warn("提现记录运营商部门 ID 非法无法创建开票待办withdrawCode={}, deptId={}",
withdrawInfo.getWithdrawCode(), merchantInfo.getDeptId());
return;
}
List<Long> assigneeUserIds = todoTaskAssigneeService.findActiveOperatorAdminUserIdsByDeptTree(deptId);
if (CollectionUtils.isEmpty(assigneeUserIds)) {
log.warn("提现记录运营商部门下没有正常管理员无法创建开票待办withdrawCode={}, deptId={}",
withdrawInfo.getWithdrawCode(), deptId);
return;
}
String routeParams = buildRouteParams(withdrawInfo);
for (Long assigneeUserId : assigneeUserIds) {
String idempotentKey = MerchantWithdrawInvoiceConstants.TASK_TYPE + ":"
+ MerchantWithdrawInvoiceConstants.BUSINESS_TYPE + ":"
+ withdrawInfo.getWithdrawCode() + ":" + assigneeUserId;
todoTaskService.createTask(TodoTaskCreateCommand.builder()
.taskType(MerchantWithdrawInvoiceConstants.TASK_TYPE)
.title("运营商提现向平台开票")
.summary("提现已成功,请向平台提交发票,提现编号:" + withdrawInfo.getWithdrawCode())
.businessType(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE)
.businessId(withdrawInfo.getWithdrawCode())
.routeName(MerchantWithdrawInvoiceConstants.ROUTE_NAME)
.routeParams(routeParams)
.assigneeUserId(assigneeUserId)
.assigneeMerchantId(withdrawInfo.getMerchantId())
.priority(TodoTaskConstants.PRIORITY_IMPORTANT)
.idempotentKey(idempotentKey)
.createBy("adapay-callback")
.build());
}
}
private String buildRouteParams(ClearingWithdrawInfo withdrawInfo) {
Map<String, Object> query = new HashMap<>(2);
query.put("merchantId", withdrawInfo.getMerchantId());
query.put("withdrawCode", withdrawInfo.getWithdrawCode());
Map<String, Object> routeParams = new HashMap<>(1);
routeParams.put("query", query);
return JSON.toJSONString(routeParams);
}
private SysTodoTask validateTodo(String withdrawCode, Long todoId, Long userId) {
SysTodoTask todoTask = todoTaskService.getForAssignee(todoId, userId);
if (!MerchantWithdrawInvoiceConstants.TASK_TYPE.equals(todoTask.getTaskType())
|| !MerchantWithdrawInvoiceConstants.BUSINESS_TYPE.equals(todoTask.getBusinessType())
|| !withdrawCode.equals(todoTask.getBusinessId())) {
throw new ServiceException("待办任务与提现开票信息不匹配");
}
return todoTask;
}
private ClearingWithdrawInvoice requireInvoice(String withdrawCode) {
ClearingWithdrawInvoice invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawCode);
if (invoice == null) {
throw new ServiceException("提现开票信息不存在");
}
return invoice;
}
private MerchantWithdrawInvoiceVO buildView(ClearingWithdrawInvoice invoice,
ClearingWithdrawInfo withdrawInfo) {
MerchantWithdrawInvoiceVO view = new MerchantWithdrawInvoiceVO();
BeanUtils.copyProperties(invoice, view);
if (withdrawInfo != null) {
view.setWithdrawAmt(withdrawInfo.getWithdrawAmt());
view.setFeeAmt(withdrawInfo.getFeeAmt());
view.setCreditedAmt(withdrawInfo.getCreditedAmt());
view.setApplicationTime(withdrawInfo.getApplicationTime());
view.setArrivalTime(withdrawInfo.getArrivalTime());
}
return view;
}
private String normalizeRequired(String value, String fieldName, int maxLength) {
String safeValue = StringUtils.trim(value);
if (StringUtils.isEmpty(safeValue)) {
throw new ServiceException(fieldName + "不能为空");
}
if (safeValue.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过" + maxLength + "个字符");
}
return safeValue;
}
private String normalizeOperator(String operatorName, Long operatorId) {
String safeOperator = StringUtils.isNotEmpty(operatorName)
? operatorName.trim() : String.valueOf(operatorId);
if (safeOperator.length() > 64) {
throw new ServiceException("操作人名称不能超过64个字符");
}
return safeOperator;
}
}

View File

@@ -0,0 +1,41 @@
package com.jsowell.pile.vo.web;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 运营商提现开票详情。
*/
@Data
public class MerchantWithdrawInvoiceVO {
private Long id;
private String withdrawCode;
private String merchantId;
private String invoiceStatus;
private String invoiceNumber;
private String voucherUrl;
private Long submittedBy;
private String submittedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submittedTime;
private String closedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date closedTime;
private String closeReason;
private BigDecimal withdrawAmt;
private BigDecimal feeAmt;
private BigDecimal creditedAmt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applicationTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date arrivalTime;
}

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jsowell.pile.mapper.ClearingWithdrawInvoiceMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.ClearingWithdrawInvoice">
<id column="id" property="id" />
<result column="withdraw_code" property="withdrawCode" />
<result column="merchant_id" property="merchantId" />
<result column="invoice_status" property="invoiceStatus" />
<result column="invoice_number" property="invoiceNumber" />
<result column="voucher_url" property="voucherUrl" />
<result column="submitted_by" property="submittedBy" />
<result column="submitted_name" property="submittedName" />
<result column="submitted_time" property="submittedTime" />
<result column="closed_by" property="closedBy" />
<result column="closed_name" property="closedName" />
<result column="closed_time" property="closedTime" />
<result column="close_reason" property="closeReason" />
<result column="create_by" property="createBy" />
<result column="create_time" property="createTime" />
<result column="update_by" property="updateBy" />
<result column="update_time" property="updateTime" />
<result column="remark" property="remark" />
<result column="del_flag" property="delFlag" />
</resultMap>
<sql id="BaseColumnList">
id, withdraw_code, merchant_id, invoice_status, invoice_number, voucher_url,
submitted_by, submitted_name, submitted_time, closed_by, closed_name, closed_time,
close_reason, create_by, create_time, update_by, update_time, remark, del_flag
</sql>
<select id="selectByWithdrawCode" resultMap="BaseResultMap">
select <include refid="BaseColumnList" />
from clearing_withdraw_invoice
where withdraw_code = #{withdrawCode}
and del_flag = '0'
limit 1
</select>
<insert id="insertSelective" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
insert into clearing_withdraw_invoice (
withdraw_code, merchant_id, invoice_status, create_by, create_time, del_flag
) values (
#{withdrawCode}, #{merchantId}, #{invoiceStatus}, #{createBy}, sysdate(), #{delFlag}
)
</insert>
<update id="markSubmitted">
update clearing_withdraw_invoice
set invoice_status = #{invoiceStatus},
invoice_number = #{invoiceNumber},
voucher_url = #{voucherUrl},
submitted_by = #{submittedBy},
submitted_name = #{submittedName},
submitted_time = #{submittedTime},
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and invoice_status = 'PENDING'
and del_flag = '0'
</update>
<update id="markClosed">
update clearing_withdraw_invoice
set invoice_status = #{invoiceStatus},
closed_by = #{closedBy},
closed_name = #{closedName},
closed_time = #{closedTime},
close_reason = #{closeReason},
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and invoice_status = 'PENDING'
and del_flag = '0'
</update>
</mapper>