feat: HCS收票单同步与任务端点接线(C109-1/2含合同回溯与人工补录/扫描编排扩展开票收票/重推端点)

This commit is contained in:
刘泉佳
2026-09-27 23:57:43 +08:00
parent e3b65697e9
commit 12e2825e55
4 changed files with 399 additions and 1 deletions
@@ -64,6 +64,16 @@ public final class HcsSyncConstant {
*/
public static final String BIZ_TYPE_PAYMENT = "payment";
/**
* 推送业务类型:开票申请(C111 开票登记)
*/
public static final String BIZ_TYPE_INVOICE_APPLICATION = "invoice-application";
/**
* 推送业务类型:收票单(C109)
*/
public static final String BIZ_TYPE_INVOICE_RECEIPT = "invoice-receipt";
/**
* 校验类型:客商风险(C101-1)
*/
@@ -34,7 +34,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.api.R;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.support.hcs.HcsContractSyncSupport;
import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
import org.springblade.transport.support.hcs.HcsMasterDataSupport;
import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
import org.springblade.transport.task.HcsPushTask;
@@ -0,0 +1,311 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support.hcs;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.dto.HcsAttachmentRefDTO;
import org.springblade.hcs.dto.HcsFormSaveVO;
import org.springblade.hcs.dto.HcsMechanismsDTO;
import org.springblade.hcs.dto.HcsReceiptBillFormDataDTO;
import org.springblade.hcs.dto.HcsReceiptBillPropsDTO;
import org.springblade.hcs.dto.HcsReceiptBillSaveDTO;
import org.springblade.hcs.dto.HcsResult;
import org.springblade.hcs.exception.HcsException;
import org.springblade.hcs.feign.IHcsClient;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.User;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* HCS 收票单同步支撑组件(C109-1 新建 / C109-2 更新,设计文档 §5.8,D13)
* <p>
* 审批通过 → C109-1;票据变更 → C109-2(顶层 fdId=hcs_fd_id 必填,只传变更字段、不带文档状态;
* v4 无作废字段,作废推送待联调确认)。合同关联:应付结算链路回溯(收票 → 应收/应付结算关联 → 结算单合同),
* 回溯不到时取页面人工补录的 hcs_contract_id/hcs_contract_no(D13)。
* fd_ap_bill_src_subject(认领应付单号键)待联调(§9-U11),一期不传。
* 返回双层嵌套 data.data.fdId → hcs_fd_id。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HcsReceiptBillSyncSupport {
/**
* 收票单附件字段标识
*/
private static final String RECEIPT_ATTACH_ENTITY_KEY = "fd_attach";
/**
* 收票日期格式(fd_bill_date 为 YYYY-MM-DD 字符串,非毫秒时间戳)
*/
private static final DateTimeFormatter BILL_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final IHcsClient hcsClient;
private final HcsProperties hcsProperties;
private final InvoiceReceiptMapper invoiceReceiptMapper;
private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final ContractManageMapper contractManageMapper;
private final HcsAttachmentSupport hcsAttachmentSupport;
/**
* 推送收票单:无合规ID 走 C109-1 新建,有合规ID 走 C109-2 更新;整体状态由调用方回写
*
* @param invoiceReceipt 收票单(approvalStatus=approved)
* @return 推送结果描述
*/
public String pushReceiptBill(InvoiceReceipt invoiceReceipt) {
String operatorLoginName = resolveLoginName(invoiceReceipt);
if (StringUtil.isBlank(invoiceReceipt.getHcsFdId())) {
HcsReceiptBillSaveDTO param = buildSaveParam(invoiceReceipt, operatorLoginName, true);
log.info("HCS收票单新建开始:invoiceNo={}", invoiceReceipt.getInvoiceNo());
HcsResult<HcsResult<HcsFormSaveVO>> result = hcsClient.addReceiveBill(param);
String fdId = extractNestedFdId(invoiceReceipt, result);
// 收票单ID 立即持久化(调用方必须保存)
invoiceReceipt.setHcsFdId(fdId);
invoiceReceiptMapper.updateById(invoiceReceipt);
log.info("HCS收票单新建成功:invoiceNo={},fdId={}", invoiceReceipt.getInvoiceNo(), fdId);
return "C109-1 收票单新建成功";
}
// C109-2 更新:顶层 fdId 必填,只传变更字段、不带文档状态
HcsReceiptBillSaveDTO param = buildSaveParam(invoiceReceipt, operatorLoginName, false);
log.info("HCS收票单更新开始:invoiceNo={},fdId={}", invoiceReceipt.getInvoiceNo(), invoiceReceipt.getHcsFdId());
HcsResult<HcsResult<Void>> result = hcsClient.updateReceiveBill(param);
if (result == null || !result.isSuccess()) {
log.error("HCS收票单更新失败响应:{},invoiceNo={}", result == null ? "无响应" : result.getMsg(), invoiceReceipt.getInvoiceNo());
throw new HcsException("C109-2 收票单更新失败:" + (result == null ? "响应为空" : result.getMsg()));
}
return "C109-2 收票单更新成功";
}
/**
* 单条手动重推:重置为待同步并清零重试计数后立即推送
*
* @param id 收票单ID
* @return 推送结果描述
*/
public String repushReceiptBill(Long id) {
InvoiceReceipt invoiceReceipt = invoiceReceiptMapper.selectById(id);
if (invoiceReceipt == null) {
throw new ServiceException("收票单不存在:" + id);
}
if (!"approved".equals(invoiceReceipt.getApprovalStatus())) {
throw new ServiceException("仅审批通过的收票单可推送合规(当前状态:" + invoiceReceipt.getApprovalStatus() + ")");
}
invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
invoiceReceipt.setHcsRetryCount(0);
invoiceReceiptMapper.updateById(invoiceReceipt);
try {
return pushReceiptBill(invoiceReceipt);
} catch (Exception exception) {
String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
markFailed(invoiceReceipt, message);
throw new ServiceException("HCS收票单推送失败:" + message);
}
}
/**
* 组装 C109-1/C109-2 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.8)
*/
private HcsReceiptBillSaveDTO buildSaveParam(InvoiceReceipt invoiceReceipt, String operatorLoginName, boolean add) {
HcsReceiptBillSaveDTO param = new HcsReceiptBillSaveDTO();
// 收票单主键:新建不传,更新必填(顶层 fdId)
param.setFdId(invoiceReceipt.getHcsFdId());
// 【必填】操作人登录名:收票单创建人登录名(§9-U2)
param.setFdLoginName(operatorLoginName);
HcsReceiptBillFormDataDTO formData = new HcsReceiptBillFormDataDTO();
HcsReceiptBillPropsDTO props = new HcsReceiptBillPropsDTO();
// 发票号码:invoiceNo
props.setFdInvoiceCode(invoiceReceipt.getInvoiceNo());
// 开票/收票日期:invoiceDate(YYYY-MM-DD 字符串)
props.setFdBillDate(formatBillDate(invoiceReceipt.getInvoiceDate()));
// 开票方(销方):issuerName
props.setFdInvoicer(invoiceReceipt.getIssuerName());
// 收票方(购方):receiverName
props.setFdBuyer(invoiceReceipt.getReceiverName());
// 价税合计金额:invoiceAmount
props.setFdInvoiceAmtTaxIncl(invoiceReceipt.getInvoiceAmount());
// 税率(小数):TMS taxRate(如 13)÷ 100 → 0.13
props.setFdTaxRate(toTaxRate(invoiceReceipt.getTaxRate()));
// 发票类型:receipt-bill.invoice-type-mapping 字典映射(§9-U3,未配置不传)
props.setFdInvoiceType(hcsProperties.getReceiptBill().getInvoiceTypeMapping().get(invoiceReceipt.getInvoiceType()));
// 合同主题/编号:应付结算链路回溯,回溯不到取人工补录(D13)
ContractReference contractReference = resolveContract(invoiceReceipt);
props.setFdContractSubject(contractReference.subject());
props.setFdContractNo(contractReference.contractNo());
// TODO 待联调(§9-U11):fd_ap_bill_src_subject 关联结算付款单的编号体系(应付单号键)v4 未明确,一期不传
// 备注:remark
props.setFdRemark(invoiceReceipt.getRemark());
formData.setDynamicProps(props);
param.setFormData(formData);
// 附件挂载(发票扫描件):fdEntityKey=fd_attach,发起带 fdBindType=ADD,更新不带
if (hcsProperties.getAttachment().isTransferEnabled() && StringUtil.isNotBlank(invoiceReceipt.getAttachmentsJson())) {
List<String> attachmentFdIds = hcsAttachmentSupport.transferAttachments(invoiceReceipt.getAttachmentsJson(), operatorLoginName);
if (!attachmentFdIds.isEmpty()) {
HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
mechanisms.setAttachment(attachmentFdIds.stream().map(fdId -> {
HcsAttachmentRefDTO attachment = new HcsAttachmentRefDTO();
// 附件ID(C103-2 转传返回)
attachment.setFdId(fdId);
// 收票单附件字段标识
attachment.setFdEntityKey(RECEIPT_ATTACH_ENTITY_KEY);
// 绑定类型:仅发起传 ADD
attachment.setFdBindType(add ? "ADD" : null);
return attachment;
}).toList());
param.setMechanisms(mechanisms);
}
}
return param;
}
/**
* 解析 C109-1 双层嵌套返回 data.data.fdId
*/
private String extractNestedFdId(InvoiceReceipt invoiceReceipt, HcsResult<HcsResult<HcsFormSaveVO>> result) {
if (result == null || !result.isSuccess() || result.getData() == null || !result.getData().isSuccess()
|| result.getData().getData() == null || StringUtil.isBlank(result.getData().getData().getFdId())) {
log.error("HCS收票单新建失败响应:{},invoiceNo={}", result == null ? "无响应" : result.getMsg(), invoiceReceipt.getInvoiceNo());
throw new HcsException("C109-1 收票单新建失败:" + (result == null ? "响应为空" : result.getMsg()));
}
return result.getData().getData().getFdId();
}
/**
* 合同引用解析:收票 → 应收结算关联 → 结算单合同 → 合同已推送传合规ID,否则传人工补录值(D13)
*/
private ContractReference resolveContract(InvoiceReceipt invoiceReceipt) {
// ① 人工补录兜底优先可直接用;先走链路回溯
InvoiceReceiptSettlement relation = invoiceReceiptSettlementMapper.selectOne(Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
.eq(InvoiceReceiptSettlement::getInvoiceReceiptId, invoiceReceipt.getId())
.last("limit 1"));
if (relation != null && relation.getFormalSettlementId() != null) {
FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId());
if (settlement != null && settlement.getContractId() != null) {
ContractManage contractManage = contractManageMapper.selectById(settlement.getContractId());
if (contractManage != null) {
// 合同已推送传合规合同ID(fd_contract_subject 支持 ID 引用),编号传 TMS 合同编号
String subject = StringUtil.isNotBlank(contractManage.getHcsFdId()) ? contractManage.getHcsFdId() : contractManage.getContractName();
return new ContractReference(subject, contractManage.getContractNo());
}
}
}
// ② 回溯不到:页面人工补录(hcs_contract_id/hcs_contract_no,D13)
if (invoiceReceipt.getHcsContractId() != null) {
ContractManage contractManage = contractManageMapper.selectById(invoiceReceipt.getHcsContractId());
if (contractManage != null) {
String subject = StringUtil.isNotBlank(contractManage.getHcsFdId()) ? contractManage.getHcsFdId() : contractManage.getContractName();
return new ContractReference(subject, StringUtil.isNotBlank(invoiceReceipt.getHcsContractNo()) ? invoiceReceipt.getHcsContractNo() : contractManage.getContractNo());
}
}
if (StringUtil.isNotBlank(invoiceReceipt.getHcsContractNo())) {
return new ContractReference(null, invoiceReceipt.getHcsContractNo());
}
log.warn("HCS收票单合同关联缺失:invoiceNo={},应付结算回溯与人工补录均为空,fd_contract_* 不传", invoiceReceipt.getInvoiceNo());
return new ContractReference(null, null);
}
/**
* 回填整体同步状态置 synced
*/
public void markSynced(InvoiceReceipt invoiceReceipt, String message) {
invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
invoiceReceipt.setHcsRetryCount(0);
invoiceReceipt.setHcsSyncTime(LocalDateTime.now());
invoiceReceipt.setHcsSyncMsg(message);
invoiceReceiptMapper.updateById(invoiceReceipt);
log.info("HCS收票单推送成功:invoiceNo={},{}", invoiceReceipt.getInvoiceNo(), message);
}
/**
* 推送失败回写:failed + 重试次数+1 + 失败原因
*/
public void markFailed(InvoiceReceipt invoiceReceipt, String message) {
invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
invoiceReceipt.setHcsRetryCount(invoiceReceipt.getHcsRetryCount() == null ? 1 : invoiceReceipt.getHcsRetryCount() + 1);
invoiceReceipt.setHcsSyncTime(LocalDateTime.now());
invoiceReceipt.setHcsSyncMsg(message);
invoiceReceiptMapper.updateById(invoiceReceipt);
log.error("HCS收票单推送失败:invoiceNo={},retryCount={},{}", invoiceReceipt.getInvoiceNo(), invoiceReceipt.getHcsRetryCount(), message);
}
/**
* 操作人登录名(收票单创建人,§9-U2)
*/
private String resolveLoginName(InvoiceReceipt invoiceReceipt) {
if (invoiceReceipt.getCreateUser() == null) {
throw new HcsException("C109 收票单推送失败,收票单创建人未维护(invoiceNo=" + invoiceReceipt.getInvoiceNo() + ")");
}
User user = UserCache.getUser(invoiceReceipt.getCreateUser());
if (user == null || StringUtil.isBlank(user.getAccount())) {
throw new HcsException("C109 收票单推送失败,创建人登录名解析不到(createUser=" + invoiceReceipt.getCreateUser() + ")");
}
return user.getAccount();
}
/**
* 收票日期格式化(fd_bill_date 为 YYYY-MM-DD 字符串)
*/
private String formatBillDate(LocalDate invoiceDate) {
return invoiceDate == null ? null : invoiceDate.format(BILL_DATE_FORMATTER);
}
/**
* 税率换算:TMS 整数值(如 13)÷ 100 → 小数(0.13)
*/
private BigDecimal toTaxRate(BigDecimal taxRate) {
return taxRate == null ? null : taxRate.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
}
/**
* 合同引用(fd_contract_subject / fd_contract_no)
*/
private record ContractReference(String subject, String contractNo) {
}
}
@@ -33,14 +33,20 @@ import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.InvoiceApplicationMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.ProjectApplyMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.support.hcs.HcsContractSyncSupport;
import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -81,9 +87,13 @@ public class HcsPushTask {
private final ProjectApplyMapper projectApplyMapper;
private final ContractManageMapper contractManageMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final InvoiceApplicationMapper invoiceApplicationMapper;
private final InvoiceReceiptMapper invoiceReceiptMapper;
private final HcsProjectSyncSupport hcsProjectSyncSupport;
private final HcsContractSyncSupport hcsContractSyncSupport;
private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
private final BladeRedis bladeRedis;
/**
@@ -109,10 +119,12 @@ public class HcsPushTask {
return;
}
try {
// 任务顺序(设计文档 §5.0):项目 → 合同 → 付款;各类互不影响
// 任务顺序(设计文档 §5.0):项目 → 合同 → 付款 → 开票 → 收票;各类互不影响
log.info("定时推送HCS项目立项:{}", pushUnsyncedProjects());
log.info("定时推送HCS合同:{}", pushUnsyncedContracts());
log.info("定时推送HCS付款链:{}", pushUnsyncedPayments());
log.info("定时推送HCS开票登记:{}", pushUnsyncedInvoiceApplications());
log.info("定时推送HCS收票单:{}", pushUnsyncedInvoiceReceipts());
} finally {
if (lockHolder.equals(bladeRedis.get(HcsSyncConstant.PUSH_TASK_LOCK_KEY))) {
bladeRedis.del(HcsSyncConstant.PUSH_TASK_LOCK_KEY);
@@ -212,4 +224,67 @@ public class HcsPushTask {
}
return summary;
}
/**
* 扫描并推送待同步/待补偿的开票申请(C111 开票登记,含金蝶开票完成后回填),返回结果汇总
*
* @return 汇总描述
*/
public String pushUnsyncedInvoiceApplications() {
int maxRetry = hcsProperties.getPush().getMaxRetry();
List<InvoiceApplication> applicationList = invoiceApplicationMapper.selectList(Wrappers.<InvoiceApplication>lambdaQuery()
.eq(InvoiceApplication::getApprovalStatus, "approved")
.in(InvoiceApplication::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
.and(wrapper -> wrapper.isNull(InvoiceApplication::getHcsRetryCount).or().lt(InvoiceApplication::getHcsRetryCount, maxRetry))
.last("limit " + hcsProperties.getPush().getBatchSize()));
int successCount = 0;
int failCount = 0;
for (InvoiceApplication application : applicationList) {
try {
hcsInvoiceRegSyncSupport.pushInvoiceRegistration(application);
successCount++;
} catch (Exception exception) {
String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
hcsInvoiceRegSyncSupport.markFailed(application, message);
failCount++;
}
}
String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", applicationList.size(), successCount, failCount, maxRetry);
if (failCount > 0) {
log.warn("HCS开票登记推送存在失败:{}", summary);
}
return summary;
}
/**
* 扫描并推送待同步/待补偿的收票单(C109),返回结果汇总
*
* @return 汇总描述
*/
public String pushUnsyncedInvoiceReceipts() {
int maxRetry = hcsProperties.getPush().getMaxRetry();
List<InvoiceReceipt> receiptList = invoiceReceiptMapper.selectList(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getApprovalStatus, "approved")
.in(InvoiceReceipt::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
.and(wrapper -> wrapper.isNull(InvoiceReceipt::getHcsRetryCount).or().lt(InvoiceReceipt::getHcsRetryCount, maxRetry))
.last("limit " + hcsProperties.getPush().getBatchSize()));
int successCount = 0;
int failCount = 0;
for (InvoiceReceipt invoiceReceipt : receiptList) {
try {
hcsReceiptBillSyncSupport.pushReceiptBill(invoiceReceipt);
hcsReceiptBillSyncSupport.markSynced(invoiceReceipt, "C109 收票单推送成功");
successCount++;
} catch (Exception exception) {
String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
hcsReceiptBillSyncSupport.markFailed(invoiceReceipt, message);
failCount++;
}
}
String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", receiptList.size(), successCount, failCount, maxRetry);
if (failCount > 0) {
log.warn("HCS收票单推送存在失败:{}", summary);
}
return summary;
}
}