fix: 修正金蝶解锁回调单号定位并新增解锁终态页面标识

- 回调方向与锁定相反:按金蝶单号+我方单号双条件反查最新locked锁记录(targetBillType回调为空不参与匹配)
- 回调处理批量化:一次IN查询+updateBatchById批量落库,消除逐张查询/更新N+1
- 新增KingdeeUnlockMark展示契约与fillUnlockMarks通用填充,付款申请/结算单/开票申请VO回填解锁时间、操作人、动作名称
- 更新金蝶单据锁定与解锁业务流程文档(回调方向、解锁后数据变化、事务语义、联调实测结论)
This commit is contained in:
刘泉佳
2026-10-15 11:29:11 +08:00
parent f5a7b76205
commit f23e24e808
10 changed files with 233 additions and 47 deletions
@@ -9,8 +9,10 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
import java.util.List;
import java.util.Map;
@@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos);
/**
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录;
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。
* 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、
* targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空,
* 故按金蝶单号+我方单号双条件反查最新 locked 锁记录;
* operationType=DELETE 时按单据类型分发废除源单并还原关联数据
*
* @param param 金蝶回调参数
@@ -66,6 +70,29 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量查源单解锁终态留痕(供列表/详情页填充解锁标识):
* 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败)
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillIds 源单主键集合
* @return sourceBillId -&gt; 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中
*/
Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识
* (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。
* 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用;
* 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用
*
* @param sourceBillType WLTMS 单据类型
* @param rows 已填充活跃锁标识的 VO 行
* @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值)
* @param &lt;T&gt; 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键)
*/
<T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap);
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
@@ -233,6 +233,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
// 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_SETTLEMENT, rows, lockMap);
}
/**
@@ -202,6 +202,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
// 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, rows, lockMap);
}
/**
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
@@ -29,6 +30,7 @@ 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.pojo.vo.KingdeeUnlockMark;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
@@ -152,8 +154,8 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
public int onUnlockNotify(BfiBillLockDTO param) {
// 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试)
if (!bfiProperties.getBillLock().isEnabled()) {
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},targetBillNo={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getTargetBillNo());
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},金蝶单号={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getSourceBillNo());
return 0;
}
if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem())
@@ -161,35 +163,41 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|| !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK");
}
if (Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空");
if (Func.isBlank(param.getSourceBillNo()) || Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceBillNo、targetBillNo不能为空");
}
// 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
int processed = 0;
for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) {
KingdeeBillLock lockRecord = this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType())
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
.orderByDesc(KingdeeBillLock::getId)
.last("limit 1"), false);
// 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、
// targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
// 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1
List<String> kingdeeBillNos = Func.toStrList(param.getSourceBillNo());
Map<String, KingdeeBillLock> latestLockMap = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos)
.eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo())
.orderByDesc(KingdeeBillLock::getId))
.stream()
.collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first));
List<KingdeeBillLock> unlockRecords = new ArrayList<>();
for (String kingdeeBillNo : kingdeeBillNos) {
KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo);
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
param.getTargetBillType(), targetBillNo);
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}",
kingdeeBillNo, param.getTargetBillNo());
continue;
}
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(param.getOperationType());
lockRecord.setUnlockTime(LocalDateTime.now());
lockRecord.setNotifyOperator(param.getOperator());
this.updateById(lockRecord);
processed++;
unlockRecords.add(lockRecord);
log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator());
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
this.restoreOnKingdeeDelete(lockRecord, param.getOperator());
}
}
return processed;
this.updateBatchById(unlockRecords);
// DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并)
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator()));
}
return unlockRecords.size();
}
/**
@@ -268,6 +276,44 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second));
}
@Override
public Map<Long, KingdeeBillLock> mapUnlockedLocks(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)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED)
.orderByDesc(KingdeeBillLock::getId));
// 降序取回后首个生效:每源单保留最新一条解锁留痕
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first));
}
@Override
public <T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap) {
if (Func.isEmpty(rows)) {
return;
}
List<Long> unlockedIds = rows.stream()
.filter(row -> !activeLockMap.containsKey(row.getId()))
.map(BaseEntity::getId).toList();
if (Func.isEmpty(unlockedIds)) {
return;
}
Map<Long, KingdeeBillLock> unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds);
for (T row : rows) {
KingdeeBillLock unlockRecord = unlockedMap.get(row.getId());
if (unlockRecord == null) {
continue;
}
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
row.setKingdeeUnlockOperationName(KingdeeLockConstant.OPERATION_DELETE.equals(unlockRecord.getOperationType())
? "金蝶删单" : "金蝶解锁");
}
}
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
@@ -240,6 +240,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
// 金蝶解锁终态标识:付款申请解锁后不作废,按 unlocked 锁记录留痕区分「金蝶删单/解锁」与普通推送失败
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_PAY_APPLY, rows, lockMap);
}
@Override