Merge branch 'feature/kingdee-bill-lock' into feature/payment-apply-bfi-sync

This commit is contained in:
刘泉佳
2026-10-12 10:53:05 +08:00
13 changed files with 285 additions and 84 deletions
@@ -44,6 +44,12 @@ public class FormalSettlementVO extends FormalSettlement {
@TableField(exist = false) private Integer attachmentCount;
@TableField(exist = false) private String attachmentSyncStatus;
@TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) @Schema(description = "金蝶锁状态:locked-锁定中/failed-锁定失败;unlocked为终态不返回,null=无锁或已解锁")
private String kingdeeLockStatus;
@TableField(exist = false) @Schema(description = "金蝶锁失败原因(仅锁定失败时有值)")
private String kingdeeLockFailReason;
@TableField(exist = false) @Schema(description = "金蝶锁重试次数(达上限5后不再自动补偿,需重推同步金蝶)")
private Integer kingdeeTryCount;
@TableField(exist = false) private String preSettlementNos;
@TableField(exist = false) private String preSettlementNo;
@TableField(exist = false) private List<FormalSettlementSource> sources;
@@ -52,6 +52,9 @@ public class InvoiceApplicationVO extends InvoiceApplication {
@TableField(exist = false) private Integer attachmentCount;
@TableField(exist = false) private String attachmentSyncStatus;
@TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private String kingdeeLockStatus;
@TableField(exist = false) private String kingdeeLockFailReason;
@TableField(exist = false) private Integer kingdeeTryCount;
@TableField(exist = false) private String createUserName;
@TableField(exist = false) private List<InvoiceApplicationSettlement> settlements;
@TableField(exist = false) private List<InvoiceApplicationSheetVO> sheets;
@@ -46,6 +46,9 @@ public class PaymentApplicationVO extends PaymentApplication {
@TableField(exist = false) private Integer attachmentCount;
@TableField(exist = false) private String attachmentSyncStatus;
@TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private String kingdeeLockStatus;
@TableField(exist = false) private String kingdeeLockFailReason;
@TableField(exist = false) private Integer kingdeeTryCount;
@TableField(exist = false) private List<PaymentApplicationInvoice> invoices;
@TableField(exist = false) private List<PaymentApplicationRecord> paymentRecords;
@TableField(exist = false) private List<PaymentApplicationSettlement> settlements;
@@ -13,6 +13,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import java.util.List;
import java.util.Map;
/**
* 金蝶单据锁定记录服务
@@ -24,7 +25,8 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
/**
* 推送金蝶成功后锁单(由各推送链路在主单事务提交后调用):
* 每张金蝶目标单一行锁记录(先落 failed 再调 lockBusiData,成功翻 locked);
* 同源单的目标单合并为一次调用(编号逗号拼接批量传参);已 locked 幂等跳过,failed 原地重锁并重置补偿计数;
* 锁单接口 1对1:逐张调用(单次一个源单号对一个金蝶单号),单张成败互不影响;
* 已 locked 幂等跳过,failed 原地重锁并重置补偿计数;
* 异常独立捕获,不影响主单同步状态
*
* @param sourceBillType WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY)
@@ -55,9 +57,22 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
void checkEditable(String sourceBillType, Long sourceBillId);
/**
* 锁定补偿:扫 failed 且未超重试上限的锁记录重调 lockBusiData(由定时任务调用)
* 批量查源单当前锁状态(供列表/详情页填充展示标识):
* 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁)
*
* @return 补偿摘要
* @param sourceBillType WLTMS 单据类型
* @param sourceBillIds 源单主键集合
* @return sourceBillId -&gt; 最新锁记录,无活跃锁的源单不在 Map 中
*/
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
* 成功翻 locked,失败 try_count+1 并记失败原因;
* 计数烧满上限后停止自动补偿(人工重推「同步金蝶」会重置计数重新接管)。锁记录落库均为批量更新
*
* @return 补偿摘要(待补偿/成功张数),供调用方记日志或页面提示
*/
String retryFailedLocks();
}
@@ -42,6 +42,7 @@ import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper;
@@ -209,9 +210,34 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
IPage<FormalSettlementVO> voPage = result.convert(this::toVO);
fillAttachmentSyncStatus(voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
return voPage;
}
/**
* 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回):
* 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废,
* failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶)
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeLockStatus(List<FormalSettlementVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, KingdeeBillLock> lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_SETTLEMENT,
rows.stream().map(FormalSettlementVO::getId).toList());
for (FormalSettlementVO vo : rows) {
KingdeeBillLock lockRecord = lockMap.get(vo.getId());
if (lockRecord == null) {
continue;
}
vo.setKingdeeLockStatus(lockRecord.getLockStatus());
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1
*
@@ -280,6 +306,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
public FormalSettlementVO detail(Long id) {
FormalSettlement settlement = existing(id);
FormalSettlementVO vo = toVO(settlement);
fillKingdeeLockStatus(List.of(vo));
List<FormalSettlementSource> sources = sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getFormalSettlementId, id).orderByAsc(FormalSettlementSource::getCreateTime));
vo.setSources(sources);
@@ -56,6 +56,7 @@ import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
@@ -180,9 +181,34 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
.orderByDesc(InvoiceApplication::getCreateTime);
IPage<InvoiceApplicationVO> voPage = page(page, wrapper).convert(this::toListVO);
fillAttachmentSyncStatus(voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
return voPage;
}
/**
* 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回):
* 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废,
* failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶)
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeLockStatus(List<InvoiceApplicationVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, KingdeeBillLock> lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_INVOICE_APPLY,
rows.stream().map(InvoiceApplicationVO::getId).toList());
for (InvoiceApplicationVO vo : rows) {
KingdeeBillLock lockRecord = lockMap.get(vo.getId());
if (lockRecord == null) {
continue;
}
vo.setKingdeeLockStatus(lockRecord.getLockStatus());
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
*
@@ -208,6 +234,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public InvoiceApplicationVO detail(Long id) {
InvoiceApplication entity = existing(id);
InvoiceApplicationVO vo = toListVO(entity);
fillKingdeeLockStatus(List.of(vo));
vo.setSettlements(settlementRelationMapper.selectList(Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationSettlement::getCreateTime)));
@@ -546,7 +573,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public String syncKingdee(Long id) {
// 单据级事务经自代理调用:单条失败只回滚自身
String billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id);
// 主单事务提交后锁定金蝶开票单(lockBusiData):每张发票张次一张金蝶单,多张逗号拼接批量锁定;
// 主单事务提交后锁定金蝶开票单(lockBusiData):每张发票张次一张金蝶单,接口1对1逐张锁定;
// 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见
String lockFailSummary = lockKingdeeBillAfterPush(id, billNo);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
@@ -556,7 +583,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
/**
* 金蝶开票单锁定(lockBusiData):A303-1 推送成功事务提交后调用,多张金蝶单批量锁定;
* 金蝶开票单锁定(lockBusiData):A303-1 推送成功事务提交后调用,接口1对1逐张锁定;
* 锁定异常独立捕获,不影响主单同步结果
*
* @param id 开票申请id
@@ -24,29 +24,33 @@ import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceApplicationMapper;
import org.springblade.transport.mapper.KingdeeBillLockMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 金蝶单据锁定记录服务实现
* <p>
* 锁定:推送链路事务提交后按源单分组批量 LOCK(每组一行到多行锁记录,先落 failed 再调 lockBusiData,
* 成功翻 locked),失败由补偿定时任务重试;解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量),
* 锁定:推送链路事务提交后逐张 LOCK(接口 1对1:单次一个源单号对一个金蝶单号,每张先落 failed 行占位再调
* lockBusiData,成功翻 locked;单张成败互不影响),失败由补偿定时任务重试;落库统一批量更新。
* 解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量),
* DELETE 时分发废除源单并还原关联数据。
* 与结算单/开票申请服务互相依赖,为避免构造器循环依赖,单据服务经 {@code SpringUtil.getBean} 运行时获取
* (与本工程 doSyncKingdee 自代理同一惯用法)
@@ -65,6 +69,7 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
private final BfiProperties bfiProperties;
private final FormalSettlementMapper formalSettlementMapper;
private final InvoiceApplicationMapper invoiceApplicationMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
@Override
public String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos) {
@@ -105,31 +110,36 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
if (pendingRecords.isEmpty()) {
return null;
}
// 同源单目标单合并一次调用:编号逗号拼接批量传参,整批同成败(应答仅聚合 updateCount)
try {
bfiService.lockBusiData(this.buildLockParam(sourceBillType, sourceBillNo, targetBillType,
pendingRecords.stream().map(KingdeeBillLock::getTargetBillNo).toList(), operator));
LocalDateTime now = LocalDateTime.now();
for (KingdeeBillLock lockRecord : pendingRecords) {
// 锁单接口 1对1:单次仅一个源单号对一个金蝶单号,逐张调用,单张成败互不影响;成败落库批量更新
LocalDateTime now = LocalDateTime.now();
List<KingdeeBillLock> lockedRecords = new ArrayList<>();
List<KingdeeBillLock> failedRecords = new ArrayList<>();
for (KingdeeBillLock lockRecord : pendingRecords) {
try {
bfiService.lockBusiData(this.buildLockParam(sourceBillType, sourceBillNo, targetBillType, lockRecord.getTargetBillNo(), operator));
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED);
lockRecord.setLockTime(now);
lockRecord.setFailReason(null);
this.updateById(lockRecord);
}
log.info("金蝶单据批量锁定成功:源单[{}:{}],金蝶单{}张[{}]", sourceBillType, sourceBillNo,
pendingRecords.size(), String.join(",", pendingRecords.stream().map(KingdeeBillLock::getTargetBillNo).toList()));
return null;
} catch (Exception exception) {
String failReason = limitText(exception.getMessage(), 500);
for (KingdeeBillLock lockRecord : pendingRecords) {
lockedRecords.add(lockRecord);
log.info("金蝶单据锁定成功:源单[{}:{}],金蝶单[{}]", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo());
} catch (Exception exception) {
String failReason = limitText(exception.getMessage(), 500);
lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1);
lockRecord.setFailReason(failReason);
this.updateById(lockRecord);
failedRecords.add(lockRecord);
log.error("金蝶单据锁定失败(待补偿重锁):源单[{}:{}],金蝶单[{}],原因:{}", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo(), failReason, exception);
}
String failedNos = pendingRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","));
log.error("金蝶单据批量锁定失败(待补偿重锁):源单[{}:{}],金蝶单{}张[{}]", sourceBillType, sourceBillNo, pendingRecords.size(), failedNos, exception);
return failedNos;
}
this.updateBatchById(lockedRecords);
this.updateBatchById(failedRecords);
if (failedRecords.isEmpty()) {
log.info("金蝶单据锁定全部成功:源单[{}:{}],金蝶单{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(),
lockedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(",")));
return null;
}
String failedNos = failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","));
log.warn("金蝶单据锁定汇总:源单[{}:{}],成功{}张,失败{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(), failedRecords.size(), failedNos);
return failedNos;
}
@Override
@@ -204,6 +214,20 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
SpringUtil.getBean(IInvoiceApplicationService.class).voidByKingdee(application.getId(), operator);
}
case KingdeeLockConstant.SOURCE_PAY_APPLY -> {
PaymentApplication application = paymentApplicationMapper.selectById(lockRecord.getSourceBillId());
if (application == null || Objects.equals(application.getIsDeleted(), 1)) {
log.warn("金蝶删单回调源付款申请不存在:id={}", lockRecord.getSourceBillId());
return;
}
if (TransportBusiConstant.VOIDED.equals(application.getApprovalStatus())) {
log.info("金蝶删单回调源付款申请[{}]已作废(幂等跳过)", application.getPaymentNo());
return;
}
// 复用 ADR-0005 解锁口径:不作废源单,kingdeeStatus→生成失败、清编号内码(原值留档),
// 不自动重推,人工核实后重新推送(A103-2 勾稽预检防重复推单)
SpringUtil.getBean(IPaymentApplicationService.class).unlockBill(application.getId(), "金蝶删单回调");
}
default -> log.warn("金蝶删单回调暂不支持的单据类型:targetBillType={}(映射预留)", lockRecord.getTargetBillType());
}
}
@@ -219,6 +243,26 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
}
@Override
public Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds) {
if (Func.isEmpty(sourceBillIds)) {
return Map.of();
}
List<KingdeeBillLock> lockRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.in(KingdeeBillLock::getSourceBillId, sourceBillIds)
.in(KingdeeBillLock::getLockStatus, List.of(KingdeeLockConstant.STATUS_LOCKED, KingdeeLockConstant.STATUS_FAILED))
.orderByAsc(KingdeeBillLock::getId));
// 升序取回后写覆盖:每源单保留最新一条;unlocked 终态不参与查询
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second));
}
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
* <p>成功翻 locked(补锁时间、清失败原因);失败 try_count+1 并记失败原因,计数烧满上限后不再自动重试
* (人工重推「同步金蝶」重置计数重新接管);成败落库均为批量更新(updateBatchById),避免逐张 update
*/
@Override
public String retryFailedLocks() {
List<KingdeeBillLock> failedList = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
@@ -227,49 +271,49 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
if (Func.isEmpty(failedList)) {
return "无待补偿锁记录";
}
// 按源单分组,组内合并一次调用(编号逗号拼接批量传参,整批同成败)
Map<String, List<KingdeeBillLock>> groupBySource = failedList.stream()
.collect(Collectors.groupingBy(record -> record.getSourceBillType() + ":" + record.getSourceBillId(), LinkedHashMap::new, Collectors.toList()));
int success = 0;
for (List<KingdeeBillLock> group : groupBySource.values()) {
KingdeeBillLock first = group.get(0);
log.info("金蝶单据锁定补偿开始:待补偿{}张", failedList.size());
LocalDateTime now = LocalDateTime.now();
List<KingdeeBillLock> lockedRecords = new ArrayList<>();
List<KingdeeBillLock> failedRecords = new ArrayList<>();
for (KingdeeBillLock lockRecord : failedList) {
String operator = Func.isBlank(lockRecord.getLockOperator()) ? "system" : lockRecord.getLockOperator();
try {
bfiService.lockBusiData(this.buildLockParam(first.getSourceBillType(), first.getSourceBillNo(), first.getTargetBillType(),
group.stream().map(KingdeeBillLock::getTargetBillNo).toList(),
Func.isBlank(first.getLockOperator()) ? "system" : first.getLockOperator()));
LocalDateTime now = LocalDateTime.now();
for (KingdeeBillLock lockRecord : group) {
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED);
lockRecord.setLockTime(now);
lockRecord.setFailReason(null);
this.updateById(lockRecord);
}
success += group.size();
bfiService.lockBusiData(this.buildLockParam(lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(),
lockRecord.getTargetBillType(), lockRecord.getTargetBillNo(), operator));
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED);
lockRecord.setLockTime(now);
lockRecord.setFailReason(null);
lockedRecords.add(lockRecord);
log.info("金蝶单据锁定补偿成功:源单[{}:{}],金蝶单[{}]",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo());
} catch (Exception exception) {
String failReason = limitText(exception.getMessage(), 500);
for (KingdeeBillLock lockRecord : group) {
lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1);
lockRecord.setFailReason(failReason);
this.updateById(lockRecord);
}
log.error("金蝶单据锁定补偿失败:源单[{}:{}],金蝶单{}张", first.getSourceBillType(), first.getSourceBillNo(), group.size(), exception);
lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1);
lockRecord.setFailReason(failReason);
failedRecords.add(lockRecord);
log.error("金蝶单据锁定补偿失败(try_count已+1,待下次补偿):源单[{}:{}],金蝶单[{}],原因:{}",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo(), failReason, exception);
}
}
return "待补偿" + failedList.size() + "张,成功" + success + "张";
this.updateBatchById(lockedRecords);
this.updateBatchById(failedRecords);
log.info("金蝶单据锁定补偿结束:待补偿{}张,成功{}张,仍失败{}张[{}]", failedList.size(), lockedRecords.size(), failedRecords.size(),
failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(",")));
return "待补偿" + failedList.size() + "张,成功" + lockedRecords.size() + "张";
}
/**
* 组装 lockBusiData 参数:WLTMS 调金蝶,源系统取配置(默认 WLTMS),目标系统 XH,
* 操作类型固定 LOCK/SAVE,多张目标单编号逗号拼接批量传参
* 操作类型固定 LOCK/SAVE;接口 1对1:单次仅一个源单号对一个金蝶单号
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillNo WLTMS 单号
* @param targetBillType 金蝶目标单据类型
* @param targetBillNos 金蝶单据编号集合
* @param targetBillNo 金蝶单据编号(单张)
* @param operator 操作人
* @return 锁单参数
*/
private BfiBillLockDTO buildLockParam(String sourceBillType, String sourceBillNo, String targetBillType, List<String> targetBillNos, String operator) {
private BfiBillLockDTO buildLockParam(String sourceBillType, String sourceBillNo, String targetBillType, String targetBillNo, String operator) {
BfiBillLockDTO param = new BfiBillLockDTO();
param.setLockAction(KingdeeLockConstant.ACTION_LOCK);
param.setSourceSystem(Func.isBlank(bfiProperties.getSrcSys()) ? KingdeeLockConstant.SYSTEM_WLTMS : bfiProperties.getSrcSys());
@@ -278,7 +322,7 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
param.setSourceBillType(sourceBillType);
param.setSourceBillNo(sourceBillNo);
param.setTargetBillType(targetBillType);
param.setTargetBillNo(String.join(",", targetBillNos));
param.setTargetBillNo(targetBillNo);
param.setTime(LocalDateTime.now().format(BFI_DATE_TIME));
param.setOperator(operator);
return param;
@@ -51,6 +51,7 @@ import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.BillLedgerMapper;
import org.springblade.transport.mapper.BillLedgerUsageMapper;
@@ -82,6 +83,7 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
import org.springblade.transport.pojo.entity.FormalSettlementSource;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.entity.PaymentApplicationInvoice;
import org.springblade.transport.pojo.entity.PaymentApplicationRecord;
@@ -93,6 +95,7 @@ import org.springblade.transport.pojo.entity.TemporaryCreditLimit;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
@@ -155,6 +158,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private final BillLedgerUsageMapper billLedgerUsageMapper;
private final TemporaryCreditLimitMapper temporaryCreditLimitMapper;
private final IFormalSettlementService formalSettlementService;
private final IKingdeeBillLockService kingdeeBillLockService;
private final IBfiService bfiService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
@@ -193,6 +197,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
return vo;
});
fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords());
fillKingdeeLockStatus(voPage.getRecords());
return voPage;
}
@@ -218,10 +223,35 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
/**
* 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回):
* 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废,
* failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶)
*
* @param rows 本页 VO 列表
*/
private void fillKingdeeLockStatus(List<PaymentApplicationVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, KingdeeBillLock> lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_PAY_APPLY,
rows.stream().map(PaymentApplicationVO::getId).toList());
for (PaymentApplicationVO vo : rows) {
KingdeeBillLock lockRecord = lockMap.get(vo.getId());
if (lockRecord == null) {
continue;
}
vo.setKingdeeLockStatus(lockRecord.getLockStatus());
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
}
@Override
public PaymentApplicationVO detail(Long id) {
PaymentApplication entity = existing(id);
PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(entity);
fillKingdeeLockStatus(List.of(vo));
vo.setInvoices(invoiceMapper.selectList(Wrappers.<PaymentApplicationInvoice>lambdaQuery()
.eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo)));
vo.setPaymentRecords(recordMapper.selectList(Wrappers.<org.springblade.transport.pojo.entity.PaymentApplicationRecord>lambdaQuery()
@@ -386,6 +416,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
public void voidBill(PaymentApplicationStatusRequest request) {
PaymentApplication entity = lockedPayment(request.getId());
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许作废");
// 金蝶锁定中禁止作废(lockBusiData 已锁单,金蝶侧未解锁前单据不可改动)
kingdeeBillLockService.checkEditable(KingdeeLockConstant.SOURCE_PAY_APPLY, request.getId());
if (isBillPayment(entity.getPaymentMethod())) releaseBillBalance(entity);
entity.setApprovalStatus(TransportBusiConstant.VOIDED);
entity.setCurrentNode("已作废");
@@ -427,9 +459,34 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
self().markKingdeeSyncFailed(id);
throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage());
}
// 主单事务提交后锁定金蝶付款申请单(lockBusiData):一张付款申请对一张金蝶付款申请单,接口1对1;
// 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见
String lockFailSummary = lockKingdeeBillAfterPush(id);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return kingdeeBillId;
return Func.isBlank(lockFailSummary) ? kingdeeBillId
: kingdeeBillId + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁";
}
/**
* 金蝶付款申请单锁定(lockBusiData):A103-1/A103-3 推送成功事务提交后调用,
* 一张付款申请对一张金蝶付款申请单;锁定异常独立捕获,不影响主单同步结果
*
* @param id 付款申请id
* @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null
*/
private String lockKingdeeBillAfterPush(Long id) {
try {
PaymentApplication entity = getById(id);
if (entity == null || Func.isBlank(entity.getKingdeeBillNo())) {
return null;
}
return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_PAY_APPLY, id, entity.getPaymentNo(),
KingdeeLockConstant.TARGET_PAY_APPLY, Func.toStrList(entity.getKingdeeBillNo()));
} catch (Exception exception) {
log.error("付款申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception);
return "锁定动作异常,已留档待补偿";
}
}
/**
@@ -34,15 +34,14 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceDownloadDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveBodyDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO;
@@ -50,10 +49,10 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO;
@@ -63,8 +62,8 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO;
@@ -116,7 +115,7 @@ public interface IBfiClient {
String INVOICE_DOWNLOAD_URL = "${thirdParty.bfi.invoiceDownloadUrl:/ierp/kapi/v2/awrp/bosext/attachment/download}";
String ATTACHMENT_UPLOAD_URL = "${thirdParty.bfi.attachmentUploadUrl:/ierp/kapi/v2/frame/attachment/uploadFile}";
String BLACKLIST_QUERY_URL = "${thirdParty.bfi.blacklistQueryUrl:/ierp/kapi/v2/awrp/bdm/bdm_blacklist_config/blacklistquery}";
String LOCK_BILL_URL = "${thirdParty.bfi.billLockUrl:/ierp/kapi/app/billLock/lockBusiData}";
String LOCK_BILL_URL = "${thirdParty.bfi.billLockUrl:/API_lockAction_Router}";
/**
* A100-1 获取令牌
@@ -494,7 +493,7 @@ public interface IBfiClient {
/**
* 单据锁定(lockBusiData):推送金蝶成功后 WLTMS 调用锁定目标单据;
* 多张单据编号逗号拼接批量传参(sourceBillNo 与 targetBillNo 顺序一一对应),
* 接口 1对1:单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo),
* 应答为 {updateCount, updateResultList} 裸结构(非 BfiResultVO 包装);
* 解锁结果由金蝶经 unLockNotifyBusiData 回调 WLTMS 通知(见 transport 侧公开端点)
*
@@ -36,7 +36,7 @@ import java.io.Serial;
* <p>lockBusiData:WLTMS 调金蝶,sourceSystem=WLTMS、targetSystem=XH、lockAction=LOCK;
* unLockNotifyBusiData:金蝶调 WLTMS,sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK,
* operationType=DELETE 时 WLTMS 侧需废除源单并还原关联数据。
* 批量传参约定:多张单据编号以英文逗号拼接(sourceBillNo 与 targetBillNo 按顺序一一对应),
* 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo),
* time/operator 按接口文档为选传字段(示例报文未携带),非空时才送</p>
*
* @author Chill
@@ -64,19 +64,19 @@ public class BfiBillLockDTO extends BfiBaseDTO {
*/
private String operationType;
/**
* 【必填】源单据类型:调用方单据类型(WLTMS 自定义:SETTLEMENT/INVOICE_APPLY,金蝶固定:ApFin_other_BT_S 等),多张同类型
* 【必填】源单据类型:调用方单据类型(WLTMS 自定义:SETTLEMENT/INVOICE_APPLY,金蝶固定:ApFin_other_BT_S 等)
*/
private String sourceBillType;
/**
* 【必填】源单据编号,多张英文逗号拼接
* 【必填】源单据编号,与 targetBillNo 一对一
*/
private String sourceBillNo;
/**
* 【必填】目标单据类型:接收方单据类型(金蝶固定:ApFin_other_BT_S 等,WLTMS 自定义:SETTLEMENT 等),多张同类型
* 【必填】目标单据类型:接收方单据类型(金蝶固定:ApFin_other_BT_S 等,WLTMS 自定义:SETTLEMENT 等)
*/
private String targetBillType;
/**
* 【必填】目标单据编号,多张英文逗号拼接,与 sourceBillNo 顺序一一对应
* 【必填】目标单据编号,与 sourceBillNo 一对一
*/
private String targetBillNo;
/**
@@ -317,7 +317,7 @@ public interface IBfiService {
BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent);
/**
* 单据锁定(lockBusiData):推送金蝶成功后锁定目标单据,多张单据编号逗号拼接批量传参,
* 单据锁定(lockBusiData):推送金蝶成功后锁定目标单据,接口 1对1(单次一个源单号对一个金蝶单号),
* 解锁结果由金蝶回调 unLockNotifyBusiData 通知
*
* @param param 锁单参数,操作类型固定 LOCK,源系统 WLTMS、目标系统 XH
@@ -388,7 +388,8 @@ public class BfiServiceImpl implements IBfiService {
return true;
}
log.error("调用BFI接口单据锁定异常响应参数:{}", JSONUtil.toJsonStr(result));
throw new BfiException("调用BFI接口单据锁定异常 " + (result == null ? "响应为空或解析失败" : "updateCount=" + result.getUpdateCount()));
throw new BfiException("调用BFI接口单据锁定异常,应答缺少updateCount字段(金蝶侧可能返回了请求回显、未实际处理锁定) updateCount="
+ (result == null ? "无响应" : result.getUpdateCount()));
} catch (BfiException e) {
log.error("调用BFI接口单据锁定,BfiException异常", e);
throw e;
@@ -1,7 +1,7 @@
# 金蝶单据锁定与解锁业务流程(lockBusiData 锁定 - unLockNotifyBusiData 回调 - 失败补偿 - 删单还原)
- 日期:2026-10-09
- 范围:正式结算单(应付 A102-2 / 应收 A302-2)、开票申请单(A303-1)推送金蝶成功后的单据锁定、金蝶解锁/删单回调、锁定失败定时补偿
- 日期:2026-10-09(2026-10-10 变更:① lockBusiData 确认为 1对1 接口,不支持逗号拼接批量,锁定/补偿改为逐张调用;② 付款申请单纳入锁单范围并完成接线)
- 范围:正式结算单(应付 A102-2 / 应收 A302-2)、开票申请单(A303-1)、付款申请单(A103-1 应付下推 / A103-3 直接保存,2026-10-10 新纳入)推送金蝶成功后的单据锁定、金蝶解锁/删单回调、锁定失败定时补偿
- 设计文档:`docs/superpowers/specs/2026-10-09-kingdee-bill-lock-unlock-design.md`
- 锁记录表:`blade_kingdee_bill_lock`(一行 = 一张金蝶目标单)
@@ -12,10 +12,13 @@
| 正式结算单(应付) | A102-2 财务应付单保存 | 结算单页面「同步金蝶」 | ApFin_other_BT_S | 推送成功后 |
| 正式结算单(应收) | A302-2 财务应收单保存 | 同上 | arfin_other_BT_S | 推送成功后 |
| 开票申请单 | A303-1 开票申请新增 | 开票申请页面「同步金蝶」 | cdm_paybill_bank_elect_BT_S(每张发票张次一张) | 推送成功后 |
| 付款申请单 | A103-1 应付下推(正式结算付款)/ A103-3 直接保存(预付款) | 付款申请页面「同步金蝶」(单笔/批量) | ap_payapply_oth_BT_S(每张付款申请一张) | 推送成功后 |
- lockBusiData:WLTMS 调金蝶,源系统 WLTMS、目标系统 XH、lockAction=LOCK
- unLockNotifyBusiData:金蝶调 WLTMS,源系统 XH、目标系统 WLTMS、lockAction=UNLOCK
- 批量约定:多张单据编号英文逗号拼接(sourceBillNo 与 targetBillNo 顺序一一对应),同一源单的多张金蝶单合并一次调用
- 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo);多张金蝶单(开票申请一张主单对 N 张金蝶单)逐张调用,单张成败互不影响,成败结果批量落库
- 付款申请天然 1对1:一张付款申请对应一张金蝶付款申请单(A103-1/A103-3 均同类型),源单号=付款申请号 paymentNo
- WLTMS 单据类型常量已预留:`KingdeeLockConstant.SOURCE_PAY_APPLY`(PAY_APPLY)/`TARGET_PAY_APPLY`(ap_payapply_oth_BT_S),且已在 `TARGET_TO_SOURCE_MAP` 反查映射中
- 出入参同构,应答为 `{updateCount, updateResultList}` 裸结构(逐单 1-成功/0-失败)
## 二、主线流程
@@ -24,13 +27,13 @@
```
用户点「同步金蝶」
├─ doSyncKingdee(单据级事务):幂等预检 → A102-2/A302-2/A303-1 保存 → 回写 kingdeeBillNo/同步状态=synced
├─ doSyncKingdee(单据级事务):幂等预检 → A102-2/A302-2/A303-1/A103-1/A103-3 保存 → 回写 kingdeeBillNo/同步状态=synced
│ └─ 事务提交
├─ 锁定(lockAfterPush):
│ ├─ 每张金蝶目标单一行锁记录,先落 failed 占位(保证补偿扫表无遗漏)
│ ├─ 同源单多张目标单合并一次调用:LOCK + SAVE,编号逗号拼接
│ ├─ 应答 updateCount>0 → 全部翻 locked ──→ 单据冻结
│ └─ 失败/超时 → 保持 failed,tryCount+1,失败原因落 fail_reason(主单同步状态不受影响)
│ ├─ 接口1对1:逐张调用 LOCK + SAVE,单张成败互不影响
│ ├─ 单张应答成功 → 该张翻 locked ──→ 单据冻结
│ └─ 单张失败/超时 → 保持 failed,tryCount+1,失败原因落 fail_reason(主单同步状态不受影响)
└─ 附件联带上传(A902,原有动作,后置不受影响)
```
@@ -38,11 +41,12 @@
- 开票申请:`作废`入口被 `checkEditable` 拦截(提示"该单据已被金蝶锁定,不可修改")
- 结算单:synced 状态本身已拦截编辑/调整/作废,锁定期被同一组守卫覆盖
- 付款申请:`作废`入口被 `checkEditable` 拦截(金蝶锁定中不可改动;草稿保存/删除仅限推送前,无需守卫)
- 重复点「同步金蝶」:推送幂等命中直接返回已有单号,已 locked 的锁记录跳过,不会重复锁
### 2. 金蝶侧解锁回调(金蝶 → WLTMS)
金蝶在星瀚侧对单据解锁或删单后,回调 `POST /kingdee-bfi/unLockNotifyBusiData`(`UNLOCK` + 逗号拼接批量):
金蝶在星瀚侧对单据解锁或删单后,回调 `POST /kingdee-bfi/unLockNotifyBusiData`(`UNLOCK`;targetBillNo 兼容单张/逗号拼接):
```
校验:sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK(否则应答失败)
@@ -57,8 +61,11 @@
│ 同步信息还原推送前状态:kingdeeSyncStatus→unsynced、
│ 清 kingdeeBillNo/kingdeeBillId/syncedTime
│ 变更流水记"金蝶删单回调自动废除",操作人=金蝶报文 operator
└─ INVOICE_APPLY → 开票申请作废,kingdeeStatus→unsynced、清单据编号,
刷新关联结算单开票状态,动作流水记 kingdee_delete
├─ INVOICE_APPLY → 开票申请作废,kingdeeStatus→unsynced、清单据编号,
│ 刷新关联结算单开票状态,动作流水记 kingdee_delete
└─ PAY_APPLY → 付款申请复用 unlockBill(id, "金蝶删单回调")(ADR-0005 口径):
kingdeeStatus→生成失败、清 kingdeeBillId/kingdeeBillNo(原值日志留档),
不作废源单、不自动重推,人工核实后重推(A103-2 勾稽预检防重复推单)
应答:{updateCount: 处理张数, updateResultList: [1]}
```
@@ -69,7 +76,7 @@
```
KingdeeBillLockRetryTask(thirdParty.bfi.billLock.retryEnabled,默认每5分钟)
└─ 扫 lock_status=failed 且 try_count<5 的锁记录
├─ 按源单分组,组内合并一次 lockBusiData 重锁
├─ 接口1对1:逐张重调 lockBusiData,单张成败互不影响;成败结果批量落库
├─ 成功 → 翻 locked,进入正常锁定态
└─ 失败 → try_count+1,超过 5 次停试,留 fail_reason 待人工排查
```
@@ -84,6 +91,8 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked
同单重新推送生成新锁记录,走新一轮循环。
付款申请双路解锁共用同一还原口径(unlockBill,ADR-0005):① 金蝶删单/解锁回调(本流程);② A103-2 定时回写"已同步单据连续两轮查询消失"兜底。两路幂等由 kingdeeStatus 状态机保证,非已生成/付款中状态自动跳过。
**业务单据生命周期**(以结算单为例):
```
@@ -103,21 +112,30 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked
1. **先占位再锁**:锁记录落库先于远程调用,补偿任务扫 `failed` 就是完整的待重试集合,不漏单
2. **锁失败不回滚推送**:金蝶单已生成,本地回滚只会制造断链;代价是短暂"已推未锁"窗口,由补偿任务闭合
3. **批量同成败**:接口应答只有聚合 updateCount,无逐单成败,整批一起翻状态,最简且不错账
3. **1对1逐张锁**:lockBusiData 单次仅支持一个源单号对一个金蝶单号,逐张调用互不连坐;成败结果 updateBatchById 批量落库,避免逐张 update
4. **回调幂等**:按 targetBillType+targetBillNo 找最新 locked 记录,找不到/已 unlocked 直接跳过应答成功,防金蝶重试风暴
5. **DELETE 还原口径**:作废源单 + 清金蝶同步信息 + 关联状态刷新;作废是终态,重推需新单重走流程(口径待业务联调确认)
6. **前端锁状态展示**(2026-10-10 新增):结算单/开票申请列表与详情 VO 填充 `kingdeeLockStatus`/`kingdeeLockFailReason`/`kingdeeTryCount`
(后端 `mapActiveLocks` 每页一次批量查锁表,无 N+1;unlocked 终态不返回即 null=无锁/已解锁)。
前端渲染:`locked` 红 tag「金蝶锁定中」并禁用编辑/作废(后端守卫是权威);`failed` 且 `tryCount<5` 橙 tag「锁定失败·补偿中」tooltip 显示原因;
`failed` 且 `tryCount≥5` 红 tag「锁定失败·已达重试上限」提示重推同步金蝶;null 不显示
7. **付款申请纳入**(2026-10-10 新增):一张付款申请=一张金蝶付款申请单,1对1约定天然满足,无需逐张拆分;
DELETE 还原不作废源单,复用既有 `unlockBill`(ADR-0005:生成失败+清编号内码+人工重推),与 A103-2 反审核删除兜底同口径;
推送结果即时回填 kingdeeBillNo(`requireBfiBillIdAndFillNo`),推送事务提交后即以该单号 `lockAfterPush`;
锁定失败摘要拼入「同步金蝶」返回值(与开票申请同款),页面直接可见
## 五、代码落点速查
| 内容 | 位置 |
| --- | --- |
| 锁单 Feign/DTO/应答 VO | `blade-bfi-api`:`BfiBillLockDTO`、`BfiBillLockResultVO`、`IBfiClient.lockBusiData`、`IBfiService.lockBusiData` |
| 锁类型常量与映射 | `transport/constant/KingdeeLockConstant` |
| 锁类型常量与映射 | `transport/constant/KingdeeLockConstant`(PAY_APPLY/ap_payapply_oth_BT_S 已进 TARGET_TO_SOURCE_MAP) |
| 锁状态展示填充 | `IKingdeeBillLockService.mapActiveLocks`(批量查活跃锁)+ `FormalSettlementServiceImpl/InvoiceApplicationServiceImpl/PaymentApplicationServiceImpl.fillKingdeeLockStatus`(列表/详情填充)+ 三个 VO 的 `kingdeeLockStatus/kingdeeLockFailReason/kingdeeTryCount` 字段 |
| 锁记录实体/服务 | `KingdeeBillLock`、`IKingdeeBillLockService` + `KingdeeBillLockServiceImpl` |
| 回调端点 | `KingdeeBfiPublicController`:`POST /kingdee-bfi/unLockNotifyBusiData`(PERMIT_ALL + TenantIgnore) |
| 补偿定时任务 | `KingdeeBillLockRetryTask`(`thirdParty.bfi.billLock.retryEnabled`,默认每5分钟) |
| 推送后锁定触发 | `FormalSettlementServiceImpl.syncKingdee`、`InvoiceApplicationServiceImpl.syncKingdee`(事务提交后) |
| 删单还原 | `FormalSettlementServiceImpl.voidByKingdee`、`InvoiceApplicationServiceImpl.voidByKingdee` |
| 推送后锁定触发 | `FormalSettlementServiceImpl.syncKingdee`、`InvoiceApplicationServiceImpl.syncKingdee`、`PaymentApplicationServiceImpl.syncKingdee`(均为事务提交后) |
| 删单还原 | `FormalSettlementServiceImpl.voidByKingdee`、`InvoiceApplicationServiceImpl.voidByKingdee`;付款申请复用 `PaymentApplicationServiceImpl.unlockBill`(ADR-0005 口径,回调分发于 `KingdeeBillLockServiceImpl.restoreOnKingdeeDelete`) |
| 建表脚本 | `doc/sql/transport/blade_kingdee_bill_lock_20261009.sql` |
## 六、联调前配置
@@ -131,4 +149,5 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked
③ 框架层规则兜底:`blade.secure.auth` 追加两 pattern 的 `permitAll()` 规则(模板与 Nacos 实际均已配)。
Nacos 实际配置已核对一致;环境级 blade-dev/test/prod.yaml 无 secure 段覆盖。
联调验证:服务启动后无令牌 POST 应返回业务参数校验错误(JSON),返回 401「请求未授权」即白名单未生效
4. 与金蝶确认 WLTMS 单据类型编码(SETTLEMENT/INVOICE_APPLY)与 DELETE 还原口径
4. 与金蝶确认 WLTMS 单据类型编码(SETTLEMENT/INVOICE_APPLY/PAY_APPLY)与 DELETE 还原口径
(付款申请 DELETE 不作废源单、走生成失败+人工重推,与 A103-2 反审核删除兜底同口径,待业务确认)