🐛 修复付款申请同步金蝶断链后重推被拦截问题,推送与回写全链路支持幂等恢复

- A102-2 应付单已生成时幂等跳过保存并续推 A103-1,A103-2 勾稽已下推成功则直接回填
- 预付款 A103-3 恢复推送前增加 A103-2 预检,避免重复推送
- 定时回写任务将生成失败单据纳入 A103-2 轮询,自动纠偏远端已生成单据
- 抽取公共方法:A103-2 取最近修改、分录标识生成、批量分批、统一日期格式(yyyy-MM-dd HH:mm:ss)
- 新增全链路逻辑梳理文档(推送链路与回写链路连贯性)
This commit is contained in:
刘泉佳
2026-09-18 14:15:07 +08:00
parent e485e920a6
commit aa22ad98d8
2 changed files with 307 additions and 48 deletions
@@ -118,6 +118,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/** 付款申请服务实现。 @author Chill */
@@ -395,7 +396,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
log.info("付款申请[{}]开始同步金蝶:paymentType={},申请金额={},kingdeeStatus={}",
entity.getPaymentNo(), entity.getPaymentType(), entity.getAppliedAmount(), entity.getKingdeeStatus());
String kingdeeBillId;
// 记录同步前状态:生成失败(上次推送中断)的恢复场景用于触发 A103-2 勾稽预检
String priorKingdeeStatus = entity.getKingdeeStatus();
String kingdeeBillId = null;
if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(entity.getPaymentType())) {
// 单据类型路由:按正式结算单结算类型——应付对应财务应付单(A102-2),应收对应财务应收单(A302-2,推送规则待业务确认)
requirePayableSettlement(entity);
@@ -405,19 +408,35 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// 数量取组内运输数量合计),财务应付单 detailentry 与付款申请 entry 一一对应
BfiEntryGroupResult entryGroupResult = resolveBfiEntryGroups(entity, context);
log.info("付款申请[{}]正式结算付款:开始推送金蝶财务应付单(A102-2),分录组{}条", entity.getPaymentNo(), entryGroupResult.entryGroups().size());
String apBillNo = pushBfiApBill(entity, entryGroupResult.entryGroups(), entryGroupResult.documentNos(), context);
BfiApBillPushOutcome apBillOutcome = pushBfiApBill(entity, entryGroupResult.entryGroups(), entryGroupResult.documentNos(), context);
// 财务应付单单据编号即时回填 kingdeeBillNo(后续付款申请单编号经 A103-2 定时回写任务覆盖)
if (Func.isNotEmpty(apBillNo)) {
entity.setKingdeeBillNo(apBillNo);
if (Func.isNotEmpty(apBillOutcome.billNo())) {
entity.setKingdeeBillNo(apBillOutcome.billNo());
}
log.info("付款申请[{}]金蝶财务应付单(A102-2)处理完成({}),单据编号[{}]已回填 kingdeeBillNo",
entity.getPaymentNo(), apBillOutcome.alreadyExist() ? "金蝶已存在,幂等跳过保存" : "本次推送成功", apBillOutcome.billNo());
if (apBillOutcome.alreadyExist()) {
// 应付单已生成说明上次同步已保存成功、在下推或落库环节中断:先经 A103-2 查询付款申请单是否已下推成功,
// 已生成则直接回填单据id与编号并视为同步成功(本地状态因异常回滚未落库),未生成才继续 A103-1 下推,
// 下推接口真正报错才算同步失败
kingdeeBillId = reconcileExistPayApply(entity);
}
if (Func.isEmpty(kingdeeBillId)) {
kingdeeBillId = pushBfiPayApplyPush(entity, entryGroupResult.entryGroups(), context);
log.info("付款申请[{}]金蝶付款申请单(A103-1 应付下推)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId);
}
log.info("付款申请[{}]金蝶财务应付单(A102-2)推送成功,单据编号[{}]已回填 kingdeeBillNo", entity.getPaymentNo(), apBillNo);
kingdeeBillId = pushBfiPayApplyPush(entity, entryGroupResult.entryGroups(), context);
log.info("付款申请[{}]金蝶付款申请单(A103-1 应付下推)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId);
} else {
// 预付款:直接推付款申请单(A103-3),不绑应付单
log.info("付款申请[{}]预付款:直接推送金蝶付款申请单(A103-3)", entity.getPaymentNo());
kingdeeBillId = pushBfiPayApply(entity, context);
log.info("付款申请[{}]金蝶付款申请单(A103-3)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId);
// 预付款:直接推付款申请单(A103-3),不绑应付单。
// 上次同步失败(生成失败)的恢复场景先经 A103-2 勾稽:金蝶已生成说明上次推送实际成功(如请求超时但远端已落单),
// 直接回填避免重复推送;未生成才继续推送,推送接口真正报错才算同步失败
if (KingdeeBfiConstant.KINGDEE_STATUS_FAILED.equals(priorKingdeeStatus)) {
kingdeeBillId = reconcileExistPayApply(entity);
}
if (Func.isEmpty(kingdeeBillId)) {
log.info("付款申请[{}]预付款:直接推送金蝶付款申请单(A103-3)", entity.getPaymentNo());
kingdeeBillId = pushBfiPayApply(entity, context);
log.info("付款申请[{}]金蝶付款申请单(A103-3)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId);
}
}
// 星瀚单据内部主键落 kingdeeBillId(附件上传等使用);
// 财务应付单单据编号已在推送时回填 kingdeeBillNo,付款申请单编号与付款状态由定时回写任务经 A103-2/A103-4 查询回填
@@ -475,10 +494,13 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
@Override
public String syncKingdeePaymentResult() {
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中(终态已付款/已关闭不再轮询)
// ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)。
// 生成失败单据一并纳入勾稽:金蝶侧已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚),
// 查到即回填纠正为已生成,避免人工重新推送产生重复单据;未查到则保持生成失败等待异常处理后重新推送
List<PaymentApplication> applications = list(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getApprovalStatus, APPROVED)
.in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, KingdeeBfiConstant.KINGDEE_STATUS_PAYING)
.in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED,
KingdeeBfiConstant.KINGDEE_STATUS_PAYING, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
.orderByAsc(PaymentApplication::getId));
IPaymentApplicationService self = SpringUtil.getBean(IPaymentApplicationService.class);
int updated = 0;
@@ -489,18 +511,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// ② A103-2 付款申请单查询:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改的一条
List<String> paymentNos = applications.stream()
.map(PaymentApplication::getPaymentNo).filter(Func::isNotEmpty).distinct().toList();
for (int from = 0; from < paymentNos.size(); from += PAY_RESULT_QUERY_BATCH) {
List<String> batch = paymentNos.subList(from, Math.min(from + PAY_RESULT_QUERY_BATCH, paymentNos.size()));
for (BfiPayApplyVO payApply : queryPayApplyPages(batch)) {
if (Func.isEmpty(payApply.getAwrpSrcCode())) {
continue;
}
BfiPayApplyVO existing = payApplyMap.get(payApply.getAwrpSrcCode());
if (existing == null || strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
payApplyMap.put(payApply.getAwrpSrcCode(), payApply);
}
}
}
batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch ->
payApplyMap.putAll(latestPayApplyBySrcCode(queryPayApplyPages(batch))));
for (PaymentApplication application : applications) {
BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo());
if (payApply == null) {
@@ -681,6 +693,75 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
});
}
/**
* A103-2 幂等预检:按来源单号查询金蝶付款申请单是否已生成,同一来源单号取最近修改的一条。
* 用于正式付款链路恢复场景:财务应付单已生成说明上次同步可能已保存成功而在下推/落库环节中断,
* 下推(A103-1)前先查询,避免重复下推产生重复付款申请单。
*
* @param paymentNo 来源单号(付款申请单号)
* @return 最近修改的付款申请单,未查询到返回 null
*/
/**
* A103-2 查询结果按来源单号归并:同一 awrp_src_code 取最近修改(modifytime 最大)的一条,
* 定时回写与恢复勾稽共用同一取数规则
*
* @param payApplies A103-2 分页查询全量结果
* @return 来源单号 -> 最近修改的付款申请单
*/
private Map<String, BfiPayApplyVO> latestPayApplyBySrcCode(List<BfiPayApplyVO> payApplies) {
Map<String, BfiPayApplyVO> latestBySrcCode = new HashMap<>();
for (BfiPayApplyVO payApply : payApplies) {
if (Func.isEmpty(payApply.getAwrpSrcCode())) {
continue;
}
BfiPayApplyVO existing = latestBySrcCode.get(payApply.getAwrpSrcCode());
if (existing == null || strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) {
latestBySrcCode.put(payApply.getAwrpSrcCode(), payApply);
}
}
return latestBySrcCode;
}
private BfiPayApplyVO queryLatestPayApply(String paymentNo) {
return latestPayApplyBySrcCode(queryPayApplyPages(List.of(paymentNo))).get(paymentNo);
}
/**
* A103-2 恢复勾稽回填:按付款申请号查询金蝶付款申请单是否已生成,
* 已生成则回填单据id与编号(编号覆盖 kingdeeBillNo,与定时回写任务语义一致)并返回星瀚单据id;
* 未生成返回 null,由调用方继续推送(推送接口真正报错才算同步失败)
*
* @param entity 付款申请实体
* @return 星瀚付款申请单id,金蝶未生成返回 null
*/
private String reconcileExistPayApply(PaymentApplication entity) {
BfiPayApplyVO existPayApply = queryLatestPayApply(entity.getPaymentNo());
if (existPayApply == null || existPayApply.getId() == null) {
return null;
}
String kingdeeBillId = String.valueOf(existPayApply.getId());
if (Func.isNotEmpty(existPayApply.getBillno())) {
entity.setKingdeeBillNo(existPayApply.getBillno());
}
log.info("付款申请[{}]经 A103-2 查得金蝶付款申请单已生成(单据编号[{}]),跳过重复推送直接回填",
entity.getPaymentNo(), existPayApply.getBillno());
return kingdeeBillId;
}
/**
* 列表按批大小切分逐批处理(末批可能不足批大小),远程接口批量入参分批用,避免单次过滤条件过长
*
* @param items 待处理列表
* @param batchSize 批大小
* @param batchConsumer 批处理逻辑
* @param <T> 元素类型
*/
private <T> void batchForeach(List<T> items, int batchSize, Consumer<List<T>> batchConsumer) {
for (int from = 0; from < items.size(); from += batchSize) {
batchConsumer.accept(items.subList(from, Math.min(from + batchSize, items.size())));
}
}
/**
* A102-4 批量查询已生成财务应付单:来源单据号批量 + 外部系统编号(防重键),自动翻页,
* 用于推送前幂等预检(awrp_src_code 支持多值,整批一次查询消除逐单远程查询 N+1)
@@ -691,11 +772,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private Map<String, String> queryApBillExistNos(List<String> awrpSrcCodes) {
Map<String, String> existBillNos = new LinkedHashMap<>();
// 防重键批量分批查询,避免单次过滤条件过长
for (int from = 0; from < awrpSrcCodes.size(); from += PAY_RESULT_QUERY_BATCH) {
List<String> batch = awrpSrcCodes.subList(from, Math.min(from + PAY_RESULT_QUERY_BATCH, awrpSrcCodes.size()));
if (batch.isEmpty()) {
continue;
}
batchForeach(awrpSrcCodes, PAY_RESULT_QUERY_BATCH, batch -> {
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
param.setAwrpSrcCode(batch);
param.setAwrpSrcSys(bfiPayApplySrcSys);
@@ -708,7 +785,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
existBillNos.put(apBill.getAwrpSrcCode(), apBill.getBillno());
}
}
}
});
return existBillNos;
}
@@ -780,6 +857,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
return left.compareTo(right);
}
/**
* 金蝶接口日期文本:yyyy-MM-dd HH:mm:ss(申请日期为空时取当天),A102-2/A103-1/A103-3 各接口统一格式
*
* @param entity 付款申请实体
* @return 日期文本
*/
private String bfiDateTimeText(PaymentApplication entity) {
LocalDate applyDate = entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate();
return applyDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
/**
* 推送金蝶付款申请单(A103-3 付款申请保存,预付款路径),返回金蝶单据id
*
@@ -790,7 +878,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
ContractManage contract = requireContract(entity, context);
String orgNumber = bfiOrgNumber(contract);
String currency = bfiCurrency(contract);
String applyDateText = (entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate()).format(DateTimeFormatter.BASIC_ISO_DATE);
// 申请日期:yyyy-MM-dd HH:mm:ss(与 A102-2/A103-1 统一,金蝶各接口日期格式要求一致)
String applyDateText = bfiDateTimeText(entity);
BigDecimal applyAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP);
BfiPayApplySaveDTO param = new BfiPayApplySaveDTO();
param.setAwrpSrcCode(entity.getPaymentNo());
@@ -829,7 +918,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
entry.setEApplyamount(applyAmount);
// entry.setEExpaydate(applyDateText);
entry.setProjectNumber(bfiProjectNumber(entity, context));
entry.setAwrpEntryCode(entity.getPaymentNo() + "-1");
entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), 1));
entry.setERemark(entity.getRemark());
param.setEntry(List.of(entry));
@@ -855,23 +944,26 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
/**
* 推送金蝶财务应付单(A102-2 财务应付单保存,正式结算付款第一步),仅校验保存成功。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询,金蝶已存在财务应付单时终止推送并提示用户。
* 推送金蝶财务应付单(A102-2 财务应付单保存,正式结算付款第一步)。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已在后续下推/落库环节中断,应续推而非拦截报错),
* 由调用方先经 A103-2 预检付款申请单是否已下推成功,再决定继续下推或直接回填。
* 明细分录按结算明细货物编码+计量单位分组推送,金额取各组结算金额(含税/不含税)。
*
* @param entity 付款申请实体
* @param entryGroups 结算明细聚合出的应付单分录组
* @param documentNos 结算明细单据号(去重),用于拼接外部系统来源单据号
* @return 财务应付单金蝶单据编号(返回结果 number 字段),用于回填 kingdeeBillNo
* @return 财务应付单推送结果(单据编号 + 是否金蝶已存在幂等跳过)
*/
private String pushBfiApBill(PaymentApplication entity, List<BfiEntryGroup> entryGroups, List<String> documentNos, KingdeeSyncContext context) {
// A102-4 幂等预检:按防重键查询金蝶是否已生成财务应付单(批量同步时整批已预热缓存,此处仅懒加载未命中编码),已处理则终止后续推送流程并提示用户
private BfiApBillPushOutcome pushBfiApBill(PaymentApplication entity, List<BfiEntryGroup> entryGroups, List<String> documentNos, KingdeeSyncContext context) {
// A102-4 幂等预检:按防重键查询金蝶是否已生成财务应付单(批量同步时整批已预热缓存,此处仅懒加载未命中编码),
// 已存在则幂等跳过保存,由调用方经 A103-2 预检后续推付款申请(A103-1)或直接回填已有付款申请单
String apBillSrcCode = ExternalSrcCodeSupport.buildSrcCode(entity.getPaymentNo(), documentNos);
String existBillNo = context.apBillNoCache().computeIfAbsent(apBillSrcCode, code ->
queryApBillExistNos(List.of(code)).getOrDefault(code, ""));
if (Func.isNotEmpty(existBillNo)) {
throw new ServiceException("推送金蝶失败:付款申请[" + entity.getPaymentNo() + "]的财务应付单已生成"
+ (Func.isBlank(existBillNo) ? "" : "(单据编号" + existBillNo + ")") + ",请勿重复推送");
log.info("付款申请[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存,继续后续下推流程", entity.getPaymentNo(), existBillNo);
return new BfiApBillPushOutcome(existBillNo, true);
}
ContractManage contract = requireContract(entity, context);
// 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可
@@ -882,8 +974,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
String orgNumber = bfiOrgNumber(contract);
// 币种取合同结算币种
String currency = bfiCurrency(contract);
// 单据日期:yyyy-MM-dd 00:00:00
String billDateText = (entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate()).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式)
String billDateText = bfiDateTimeText(entity);
BfiApBillSaveDTO param = new BfiApBillSaveDTO();
// 【必填】单据类型.编码
param.setBilltypeidNumber(billtypeNumber);
@@ -950,7 +1042,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值)
entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST);
// 【必填】外部系统来源分录标识(候选键),A103-1 下推时以 awrp_finapbill_entryid 关联此行
entry.setAwrpEntryCode(entity.getPaymentNo() + "-" + (index + 1));
entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), index + 1));
// 调试
@@ -975,7 +1067,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
BfiBillSaveResultVO saveResult = bfiService.saveApBill(param);
// 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
return saveResult.getResult().get(0).getNumber();
return new BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false);
}
/**
@@ -989,8 +1081,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private String pushBfiPayApplyPush(PaymentApplication entity, List<BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
ContractManage contract = requireContract(entity, context);
String orgNumber = bfiOrgNumber(contract);
// 申请日期:yyyy-MM-dd 00:00:00(与 A102-2 单据日期格式统一)
String applyDateText = (entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate()).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
// 申请日期:yyyy-MM-dd HH:mm:ss(与 A102-2 单据日期格式统一)
String applyDateText = bfiDateTimeText(entity);
BfiPayApplyPushDTO param = new BfiPayApplyPushDTO();
param.setAwrpSrcCode(entity.getPaymentNo());
param.setAwrpSrcSys(bfiPayApplySrcSys);
@@ -1011,8 +1103,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
// 正式付款固定 201
entry.setEPaymenttypeNumber(KingdeeBfiConstant.BFI_PAYMENT_TYPE_FORMAL);
// 关联 A102-2 财务应付单分录,对应应付单明细的 awrp_entry_code
entry.setAwrpFinapbillEntryid(entity.getPaymentNo() + "-" + (index + 1));
entry.setAwrpEntryCode(entity.getPaymentNo() + "-" + (index + 1));
entry.setAwrpFinapbillEntryid(bfiEntryCode(entity.getPaymentNo(), index + 1));
entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), index + 1));
entry.setEAssacct(entity.getBankAccount());
// TODO 待确认:往来银行为必填编码(e_bebank_number),当前暂传银行名称,待建立银行档案编码映射后调整
entry.setEBebankNumber(entity.getBankName());
@@ -1054,6 +1146,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private record BfiEntryGroupResult(List<BfiEntryGroup> entryGroups, List<String> documentNos) {
}
/**
* 财务应付单(A102-2)推送结果
*
* @param billNo 财务应付单单据编号(金蝶已存在时返回已有单据编号)
* @param alreadyExist 金蝶是否已存在该防重键的财务应付单,true 表示本次幂等跳过保存(上次同步已在后续环节中断)
*/
private record BfiApBillPushOutcome(String billNo, boolean alreadyExist) {
}
/**
* 解析付款申请关联的正式结算明细,按货物编码+计量单位聚合为应付单分录组。
* <p>物料编码:货物费用行/结算明细货物类型(支持“、”分隔多类型)匹配货物类型档案货物编码;
@@ -1117,6 +1218,19 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
return ExternalSrcCodeSupport.buildSrcCode(entity.getPaymentNo(), settlementDocumentNos(settlementDetails(entity, context)));
}
/**
* 金蝶分录标识:付款申请号-分录序号(1 起)。
* A102-2 明细 awrp_entry_code 与 A103-1 awrp_finapbill_entryid/awrp_entry_code 必须由本方法统一生成,
* 保证应付下推勾稽关联一致,禁止各接口自行拼接
*
* @param paymentNo 付款申请号
* @param seq 分录序号(1 起)
* @return 分录标识
*/
private String bfiEntryCode(String paymentNo, int seq) {
return paymentNo + "-" + seq;
}
/**
* 批量同步前 A102-4 预检:一次批量查询整批付款申请的财务应付单是否已生成,
* 结果(含"未生成"标记)写入上下文缓存,供逐单推送时复用,消除逐单远程查询(N+1)。
@@ -0,0 +1,145 @@
# 付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性)
> 关联文档:
> - `2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`
> - `2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`
> - `../TMS业财融合-集成流程图文字版与业务流程梳理.md`(§4.3 流程步骤、§4.4 关键规则)
>
> 本文为 2026-09-18 幂等恢复改造后的全链路现状梳理,代码落点 `PaymentApplicationServiceImpl`。
## 1. 背景与范围
付款申请审批通过后与金蝶(星瀚)的双向同步:
- **推送链路(TMS → 金蝶)**:正式结算付款两步链路 A102-2 财务应付单保存 → A103-1 应付下推付款申请;预付款一步链路 A103-3 预付款保存。
- **回写链路(金蝶 → TMS)**:定时任务经 A103-2 付款申请单查询回写单据与付款状态,经 A103-4 付款处理查询回填付款记录。
原实现两步推送同处一个本地事务,A102-2 成功后 A103-1 失败会导致"金蝶已生成、本地回滚为生成失败"的断链,重试被"请勿重复推送"拦截而死锁。本次改造确立全链路恢复原则:
> **以金蝶侧实际生成为准:已生成则幂等跳过/回填勾稽,未生成才续推;推送接口真正报错才算同步失败。**
## 2. 接口与角色清单
| 接口 | 名称 | 方向 | 方法(TMS 侧) | 用途 |
| --- | --- | --- | --- | --- |
| A102-2 | 财务应付单保存 | 推送 | `pushBfiApBill` | 正式结算付款第一步 |
| A102-4 | 财务应付单查询 | 查询 | `queryApBillExistNos` | 推送前幂等预检(防重键) |
| A103-1 | 付款申请保存(应付下推) | 推送 | `pushBfiPayApplyPush` | 正式结算付款第二步 |
| A103-2 | 付款申请单查询 | 查询 | `queryPayApplyPages` / `queryLatestPayApply` | 恢复勾稽 + 定时回写 |
| A103-3 | 付款申请保存(预付款) | 推送 | `pushBfiPayApply` | 预付款一步链路 |
| A103-4 | 付款处理查询 | 查询 | `queryPayBillPages` | 回填付款记录 |
**防重键体系**(`awrp_src_sys = WLTMS`,常量统一在 `KingdeeBfiConstant`):
| 键 | 取值 | 使用接口 |
| --- | --- | --- |
| 付款申请单来源单号 | `awrp_src_code = paymentNo` | A103-1 / A103-2 / A103-3 |
| 财务应付单来源单号 | `awrp_src_code = paymentNo_结算明细单据号拼接`(`ExternalSrcCodeSupport.buildSrcCode`) | A102-2 / A102-4 |
| 分录标识 | `bfiEntryCode = paymentNo-分录序号(1 起)` | A102-2 `awrp_entry_code`、A103-1 `awrp_finapbill_entryid` / `awrp_entry_code`、A103-3(序号固定 1) |
公共约定:各接口日期文本统一 `yyyy-MM-dd HH:mm:ss`(`bfiDateTimeText`,申请日期为空取当天);分页查询统一 `BfiUtil.queryAllPages` 自动翻页(页大小 200、最大 50 页保护);批量入参按 50 单/批(`batchForeach`);A103-2 结果按来源单号取最近修改一条(`latestPayApplyBySrcCode`)。
## 3. 状态机
本地 `kingdeeStatus` 流转与 A103-2 状态映射(`kingdeeResultStatus`):
```
unsynced ──推送成功──> synced ──paystatus=Inpayment──> paying ──paystatus=Alreadypay──> paid(终态)
│ │ │
│ └──billstatus=D──> closed(终态) └──billstatus=D──> closed(终态)
└──推送异常(事务回滚 + 独立事务落状态)──> failed ──重推成功或回写勾稽纠偏──> synced/paying/paid
```
| A103-2 返回 | 本地状态 | 是否终态 |
| --- | --- | --- |
| paystatus=Alreadypay | paid | 是(不再轮询) |
| billstatus=D(已关闭) | closed | 是(不再轮询) |
| paystatus=Inpayment | paying | 否(继续轮询) |
| 其余(A暂存/B已提交/C已审核 + Unpaid) | synced | 否(继续轮询) |
| 无返回(金蝶未生成) | 状态不变 | — |
门禁:仅 `approvalStatus = approved` 允许同步;已 synced/paying/paid/closed 在 `doSyncKingdee` 入口幂等跳过并返回既有单据 id。
## 4. 推送链路完整逻辑(TMS → 金蝶)
### 4.1 入口与事务边界
- 单条:`POST /sync-kingdee` → `syncKingdee(id)`;批量:`POST /sync-kingdee-batch` → `syncKingdeeBatch(ids)`。
- 经自代理逐单调用 `doSyncKingdee`:**单据级事务**,单条失败只回滚自身,批量不中断。
- 失败时在独立事务 `markKingdeeSyncFailed` 落 `failed`(仅 approved 单据生效)。
- 批量同步共享 `KingdeeSyncContext`:档案缓存(合同/客商/项目/货物类型/结算明细)+ 应付单防重键缓存,批量 A102-4 预热一次(`preloadApBillExistNos`,仅正式结算单据),消除逐单远程查询 N+1。
### 4.2 正式结算付款链路(settlement_payment)
| 步骤 | 动作 | 说明 |
| --- | --- | --- |
| 1 | `requirePayableSettlement` | 结算类型路由:应付放行,应收(A302-2)待业务确认暂拦截 |
| 2 | `resolveBfiEntryGroups` | 结算明细按货物编码+计量单位聚合分录组(金额取结算金额含税/不含税) |
| 3 | A102-4 幂等预检 | 按防重键查应付单是否已生成(上下文缓存优先,懒加载兜底) |
| 3a | **已生成 → 幂等跳过保存**,返回已有单据编号并回填 `kingdeeBillNo` | 说明上次同步已在后续环节中断,应续推而非拦截报错 |
| 3b | 未生成 → A102-2 保存 | 仅校验保存成功,应付单 id 不落库;单据编号回填 `kingdeeBillNo` |
| 4 | **已存在时先 A103-2 恢复勾稽**(`reconcileExistPayApply`) | 付款申请单已生成 → 回填 `kingdeeBillId/kingdeeBillNo`,直接视为同步成功,跳过下推 |
| 5 | 勾稽未命中 → A103-1 应付下推 | 分录经 `bfiEntryCode` 与 A102-2 明细一一关联;返回星瀚单据 id |
| 6 | 落库 | `kingdeeBillId = 付款申请单 id`,`kingdeeStatus = synced` |
### 4.3 预付款链路(project_advance / progress_advance)
| 步骤 | 动作 | 说明 |
| --- | --- | --- |
| 1 | 同步前状态为 `failed` 时先 A103-2 恢复勾稽 | 金蝶已生成 → 回填并视为成功,避免重推产生重复单据;首推(状态非 failed)不多一次远程查询 |
| 2 | 勾稽未命中 → A103-3 保存 | 不绑应付单;返回星瀚单据 id |
| 3 | 落库 | 同 4.2 第 6 步 |
## 5. 回写链路完整逻辑(金蝶 → TMS)
触发:`PaymentKingdeeResultTask` 定时任务(默认每 10 分钟,`thirdParty.bfi.payResult.syncEnabled/cron`),也可手动 `POST /sync-kingdee-result`;逻辑收敛在 `syncKingdeePaymentResult()`。
### 5.1 ① A103-2 单据与付款状态回写
- 轮询范围:`approved` 且 `kingdeeStatus IN (synced, paying, failed)`;paid/closed 终态不再轮询。
- **failed 纳入勾稽**:金蝶已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚),查到即经 `applyPayApplyResult` 回填 `kingdeeBillNo/billno`、`kingdeeBillId/id`、状态、已付金额(分录 `e_paidamt` 汇总),自动纠偏,防止人工重推重复;未查到保持 failed 等待异常处理后重新推送。**任务只勾稽回写,不主动推送**。
- 付款完成(paid)联动:清理 MOCK 付款记录、履约付款数据(预结算预付记录、正式结算付款记录)。
### 5.2 ② A103-4 付款记录回填
- 候选:付款中/已付款单据(本轮 A103-2 查到 `paystatus ≠ Unpaid`)+ 已付款但无真实付款记录的单据(补偿轮询)。
- 按付款人(公司)编码分组查询(编码优先取 A103-2 `payorg_number`,兜底合同组织;业务日期窗口最近 1 个月,接口限制)。
- 勾稽:付款处理单源单 id(= `kingdeeBillId`)优先,源单编号(= `kingdeeBillNo`)兜底(`matchPayBill`)。
- 回填(`applyPayBillRecord`):按付款处理单号 upsert 付款记录(金额、日期、凭证留档 JSON)。
## 6. 两边逻辑连贯性对照
### 6.1 正常时序(正式结算付款)
```
TMS 审批通过 → A102-2 保存应付单(回填 kingdeeBillNo)→ A103-1 下推付款申请(落 kingdeeBillId,置 synced)
→ 任务 A103-2 回写(billno 覆盖 kingdeeBillNo,状态 synced→paying→paid)→ 任务 A103-4 回填付款记录 → 终态
```
### 6.2 异常场景矩阵(两边行为与闭环路径)
| # | 断点场景 | 金蝶侧实际 | 本地状态 | 恢复路径 | 闭环 |
| --- | --- | --- | --- | --- | --- |
| 1 | 推送前校验/档案缺失失败(A102-2 之前) | 无任何单据 | failed | 重新推送走全链路首推 | ✅ |
| 2 | A102-2 成功、A103-1 报错(如分录校验失败) | 应付单已生成 | failed(事务回滚,编号回填一并回滚) | 重推:A102-4 预检命中 → 幂等跳过保存 → A103-2 勾稽未命中 → A103-1 续推;下推再报错才真实失败 | ✅ 本次改造 |
| 3 | A103-1 远端已落单但响应超时/本地回滚 | 应付单 + 付款申请单均已生成 | failed | 重推:A102-4 命中跳过 → A103-2 勾稽命中 → 回填置 synced;或定时任务自动纠偏 | ✅ 本次改造 |
| 4 | 预付款 A103-3 远端成功但本地失败 | 付款申请单已生成 | failed | 重推前 A103-2 预检命中 → 回填跳过;或定时任务纠偏 | ✅ 本次改造 |
| 5 | 推送成功但 kingdeeBillNo 尚为应付单编号 | 付款申请单已生成 | synced | 定时任务 A103-2 回写覆盖为付款申请单编号(字段语义随阶段演进,设计如此) | ✅ |
| 6 | 手工重推与定时任务并发纠偏 | 单据唯一(防重键唯一) | — | 两侧均为"先查后动":勾稽只读远端按 paymentNo 唯一归并,重复执行幂等无冲突 | ✅ |
| 7 | 金蝶反审核删除后重推 | 无单据 | failed | 预检查无 → 重新推送生成新单据(符合"金蝶反审核+删除后 ERP 重推"规则) | ✅ |
| 8 | failed 且金蝶确无单据(如档案长期缺失) | 无单据 | failed | 每轮任务空查一次 A103-2(量级 = failed 单据数),状态保持,等待人工处理 | ⚠️ 已知边界 |
### 6.3 连贯性结论
1. **推送侧自洽**:三条件分支(应付单已存在 / 付款申请单已存在 / 均不存在)覆盖全部断点组合,任一断点重推均从断点续传而非从头重推,且以远端实际为准。
2. **回写侧衔接**:恢复成功后本地状态为 synced(或直接 paid/paying),落入任务轮询范围,付款状态与付款记录由任务持续驱动至终态,无需再次人工干预。
3. **双通道兜底**:手工重推(勾稽预检)与定时任务(勾稽纠偏)对同一断点均可闭环,先后到达不冲突。
4. **语义一致性**:防重键、分录标识、日期格式、A103-2 归并规则均已收敛为统一生成点(`apBillSrcCode`/`bfiEntryCode`/`bfiDateTimeText`/`latestPayApplyBySrcCode`/`batchForeach`),两侧关联字段不存在漂移可能。
## 7. 已知边界与注意事项
1. **failed 空查**:确无金蝶单据的 failed 单据会被任务持续空查(每轮 A103-2 一次),如量大可考虑按申请日期窗口截断或引入重试次数上限(当前不加,保持简单)。
2. **`kingdeeBillNo` 阶段语义**:推送瞬间为应付单编号,勾稽/回写后为付款申请单编号;依赖该字段展示时以回写后的值为准。
3. **应收结算(A302-2)未接入**:结算类型为应收的单据在 `requirePayableSettlement` 拦截,推送规则待业务确认。
4. **A102-2/A103-1 联调临时取值**:物料/计量单位/项目编码仍传金蝶测试档案值(调试块),档案对齐后需拆除,见 2026-09-16 技术文档遗留项。
5. **A103-2 勾稽不校验金额**:恢复勾稽以"来源单号存在"为准,不比对金额与分录;若远端单据被人工修改,以远端为准回写(与回写任务原则一致)。