feat: 正式结算单/开票申请同步金蝶后联带锁单,支持解锁回调与金蝶删单还原

This commit is contained in:
刘泉佳
2026-10-09 19:52:15 +08:00
parent 92a975b5e4
commit e08be9a297
12 changed files with 855 additions and 0 deletions
@@ -0,0 +1,87 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.core.tenant.mp.TenantEntity;
import java.io.Serial;
import java.time.LocalDateTime;
/**
* 金蝶单据锁定记录实体类
* <p>
* 一行 = 一张金蝶目标单:推送成功后 lockBusiData 锁定(FAILED→LOCKED),金蝶经 unLockNotifyBusiData
* 回调解锁(→UNLOCKED,DELETE 时废除源单并还原关联数据);同时承担当前锁状态(编辑守卫/回调定位)
* 与锁定补偿扫表(FAILED+try_count)、锁定/解锁留痕
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blade_kingdee_bill_lock")
@Schema(description = "金蝶单据锁定记录")
public class KingdeeBillLock extends TenantEntity {
@Serial private static final long serialVersionUID = 1L;
/**
* WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY,预留 PAY_APPLY/RECEIVED_INVOICE)
*/
private String sourceBillType;
/**
* WLTMS 业务主键
*/
private Long sourceBillId;
/**
* WLTMS 单号(正式结算单号/开票申请单号)
*/
private String sourceBillNo;
/**
* 金蝶单据类型(ApFin_other_BT_S/arfin_other_BT_S/cdm_paybill_bank_elect_BT_S 等固定值)
*/
private String targetBillType;
/**
* 金蝶单据编号(即业务单 kingdeeBillNo,开票申请按发票张次逐张一行)
*/
private String targetBillNo;
/**
* 锁状态:failed-锁定失败(补偿重试对象)/locked-金蝶锁定中/unlocked-已解锁(终态)
*/
private String lockStatus;
/**
* 最近动作来源操作类型:SAVE-单据新增,DELETE-单据删除(DELETE 回调触发废除还原)
*/
private String operationType;
/**
* 锁定重试次数(补偿任务用,超上限停试)
*/
private Integer tryCount;
/**
* 锁定时间
*/
private LocalDateTime lockTime;
/**
* 解锁时间
*/
private LocalDateTime unlockTime;
/**
* 推单发起人(锁定操作人)
*/
private String lockOperator;
/**
* 回调操作人(金蝶侧操作人)
*/
private String notifyOperator;
/**
* 锁定失败原因
*/
private String failReason;
}
@@ -0,0 +1,91 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
/**
* 金蝶单据锁定/解锁(lockBusiData / unLockNotifyBusiData)固定值。
* <p>lockBusiData 由 WLTMS 调金蝶(源系统 WLTMS、目标系统 XH),
* unLockNotifyBusiData 由金蝶调 WLTMS(源系统 XH、目标系统 WLTMS);
* WLTMS 单据类型自定义,金蝶目标单据类型为固定值</p>
*
* @author Chill
*/
public final class KingdeeLockConstant {
/** 锁动作:锁定 */
public static final String ACTION_LOCK = "LOCK";
/** 锁动作:解锁 */
public static final String ACTION_UNLOCK = "UNLOCK";
/** 来源操作类型:单据新增 */
public static final String OPERATION_SAVE = "SAVE";
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
/** 系统标识:金蝶星瀚 XH */
public static final String SYSTEM_XH = "XH";
/** 锁状态:锁定失败(补偿定时任务的重试对象) */
public static final String STATUS_FAILED = "failed";
/** 锁状态:金蝶锁定中(源单冻结,不可编辑/作废) */
public static final String STATUS_LOCKED = "locked";
/** 锁状态:已解锁(终态;同单重推生成新锁记录) */
public static final String STATUS_UNLOCKED = "unlocked";
/** WLTMS 单据类型:正式结算单(应付/应收共用,按结算单 settlementType 区分金蝶目标类型) */
public static final String SOURCE_SETTLEMENT = "SETTLEMENT";
/** WLTMS 单据类型:开票申请单 */
public static final String SOURCE_INVOICE_APPLY = "INVOICE_APPLY";
/** WLTMS 单据类型:付款申请单(映射预留,本期不锁) */
public static final String SOURCE_PAY_APPLY = "PAY_APPLY";
/** WLTMS 单据类型:收票单(映射预留,本期不锁) */
public static final String SOURCE_RECEIVED_INVOICE = "RECEIVED_INVOICE";
/** 金蝶目标单据类型:财务应付单(A102-2,应付结算单推送后锁定) */
public static final String TARGET_AP_BILL = "ApFin_other_BT_S";
/** 金蝶目标单据类型:财务应收单(A302-2,应收结算单推送后锁定) */
public static final String TARGET_AR_BILL = "arfin_other_BT_S";
/** 金蝶目标单据类型:开票单(A303-1 开票申请单,按发票张次逐张一行锁记录) */
public static final String TARGET_INVOICE_BILL = "cdm_paybill_bank_elect_BT_S";
/** 金蝶目标单据类型:付款申请单(映射预留,本期不锁) */
public static final String TARGET_PAY_APPLY = "ap_payapply_oth_BT_S";
/** 金蝶目标单据类型:收票单(映射预留,本期不锁) */
public static final String TARGET_RECEIVED_INVOICE = "cdm_recbill_bank_BT_S";
/** 锁定重试上限:补偿任务超过该次数不再自动重试,人工排查(失败原因见 fail_reason) */
public static final int RETRY_MAX_COUNT = 5;
/** 金蝶目标单据类型 -> WLTMS 单据类型反查映射(解锁回调按 targetBillType 分发废除还原用) */
public static final Map<String, String> TARGET_TO_SOURCE_MAP = Map.ofEntries(
Map.entry(TARGET_AP_BILL, SOURCE_SETTLEMENT),
Map.entry(TARGET_AR_BILL, SOURCE_SETTLEMENT),
Map.entry(TARGET_INVOICE_BILL, SOURCE_INVOICE_APPLY),
Map.entry(TARGET_PAY_APPLY, SOURCE_PAY_APPLY),
Map.entry(TARGET_RECEIVED_INVOICE, SOURCE_RECEIVED_INVOICE)
);
private KingdeeLockConstant() {
}
}
@@ -0,0 +1,67 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.alibaba.fastjson2.JSON;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.secure.constant.AuthConstant;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 金蝶单据锁定回调 控制器
* <p>
* unLockNotifyBusiData:金蝶(星瀚)侧解锁/删单后回调 WLTMS 送结果,
* 无登录态公开端点(模式同 CustomerArchivePublicController);
* operationType=DELETE 时 WLTMS 废除源单并还原关联数据。
* 应答与 lockBusiData 出参同构:{updateCount, updateResultList},逐单处理(1-成功,0-未匹配/失败)
*
* @author Chill
*/
@Slf4j
@RestController
@AllArgsConstructor
@TenantIgnore
@PreAuth(AuthConstant.PERMIT_ALL)
@RequestMapping("kingdee-bfi")
@Tag(name = "金蝶单据锁定回调", description = "金蝶星瀚调用:单据解锁/删单通知")
public class KingdeeBfiPublicController {
private final IKingdeeBillLockService kingdeeBillLockService;
/**
* 单据解锁通知(unLockNotifyBusiData):多张单据编号逗号拼接批量回调,逐张处理
*
* @param param 金蝶回调参数
* @return 处理结果,与 lockBusiData 出参同构
*/
@PostMapping("unLockNotifyBusiData")
@Operation(summary = "单据解锁通知", description = "金蝶侧解锁/删单后回调;operationType=DELETE 时废除源单并还原关联数据")
public BfiBillLockResultVO unLockNotifyBusiData(@RequestBody BfiBillLockDTO param) {
log.info("金蝶单据解锁回调:{}", JSON.toJSONString(param));
try {
return BfiBillLockResultVO.of(kingdeeBillLockService.onUnlockNotify(param));
} catch (Exception exception) {
log.error("金蝶单据解锁回调处理失败:lockAction={},operationType={},targetBillType={},targetBillNo={}",
param == null ? null : param.getLockAction(), param == null ? null : param.getOperationType(),
param == null ? null : param.getTargetBillType(), param == null ? null : param.getTargetBillNo(), exception);
return BfiBillLockResultVO.of(0);
}
}
}
@@ -0,0 +1,18 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
/** 金蝶单据锁定记录 Mapper @author Chill */
@Mapper
public interface KingdeeBillLockMapper extends BaseMapper<KingdeeBillLock> {
}
@@ -42,6 +42,15 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
void voidBill(FormalSettlementStatusRequest request);
String syncKingdee(Long id);
/**
* 金蝶删单回调废除(unLockNotifyBusiData DELETE 分发):作废结算单、同步信息还原推送前状态
* (kingdeeSyncStatus→unsynced,清单据编号/星瀚内码/同步时间),操作人取金蝶回调报文
*
* @param id 正式结算单id
* @param operator 金蝶侧操作人
*/
void voidByKingdee(Long id, String operator);
/**
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
* A102-2/A302-2 保存并回写结算单单据编号与星瀚内码(kingdeeBillId)
@@ -56,6 +56,15 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
void voidBill(InvoiceApplicationStatusRequest request);
String syncKingdee(Long id);
/**
* 金蝶删单回调作废(unLockNotifyBusiData DELETE 分发):作废开票申请、同步信息还原
* (kingdeeStatus→unsynced,清单据编号/同步时间)并刷新关联结算单开票状态,操作人取金蝶回调报文
*
* @param id 开票申请id
* @param operator 金蝶侧操作人
*/
void voidByKingdee(Long id, String operator);
/**
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
* A303-1 保存并回写开票申请单单据编号与星瀚内码(kingdeeBillId)
@@ -0,0 +1,62 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import java.util.List;
/**
* 金蝶单据锁定记录服务
*
* @author Chill
*/
public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
/**
* 推送金蝶成功后锁单(由各推送链路在主单事务提交后调用):
* 每张金蝶目标单一行锁记录(先落 failed 再调 lockBusiData,成功翻 locked);
* 同源单的目标单合并为一次调用(编号逗号拼接批量传参);已 locked 幂等跳过,failed 原地重锁;
* 异常独立捕获,不影响主单同步状态
*
* @param sourceBillType WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY)
* @param sourceBillId WLTMS 业务主键
* @param sourceBillNo WLTMS 单号
* @param targetBillType 金蝶目标单据类型
* @param targetBillNos 金蝶单据编号集合(开票申请为多张)
*/
void lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos);
/**
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录;
* operationType=DELETE 时按单据类型分发废除源单并还原关联数据
*
* @param param 金蝶回调参数
* @return 处理单据数(应答 updateCount)
*/
int onUnlockNotify(BfiBillLockDTO param);
/**
* 编辑守卫:源单存在 locked 锁记录时抛 ServiceException(金蝶锁定中不可修改)
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillId WLTMS 业务主键
*/
void checkEditable(String sourceBillType, Long sourceBillId);
/**
* 锁定补偿:扫 failed 且未超重试上限的锁记录重调 lockBusiData(由定时任务调用)
*
* @return 补偿摘要
*/
String retryFailedLocks();
}
@@ -41,6 +41,7 @@ import org.springblade.transport.mapper.PaymentApplicationSettlementMapper;
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.constant.TransportBusiConstant;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper;
@@ -82,6 +83,7 @@ import org.springblade.transport.pojo.vo.PreSettlementVO;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IWaybillService;
@@ -131,6 +133,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
/** 金蝶票池认领闸门(收票服务承载 A801-1 重查与可用性口径) */
private final IInvoiceReceiptService invoiceReceiptService;
/** 金蝶单据锁定记录服务(lockBusiData 锁定/补偿/解锁守卫) */
private final IKingdeeBillLockService kingdeeBillLockService;
/** 金蝶应收单勾稽 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
private static final String AR_RESULT_SYNC_LOCK_PREFIX = "transport:formal-settlement:kingdee-ar-result-sync:";
@@ -563,15 +568,70 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废正式结算单", request.getReason());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void voidByKingdee(Long id, String operator) {
FormalSettlement settlement = existing(id);
log.info("正式结算单[{}]金蝶删单回调废除:金蝶操作人={}", settlement.getFormalSettlementNo(), operator);
// 作废并还原同步信息为推送前状态:updateById 忽略 null 字段,清空字段须显式 set null
this.update(Wrappers.<FormalSettlement>lambdaUpdate()
.eq(FormalSettlement::getId, id)
.set(FormalSettlement::getApprovalStatus, TransportBusiConstant.VOIDED)
.set(FormalSettlement::getCurrentNode, "已作废")
.set(FormalSettlement::getCurrentProcessor, operator)
.set(FormalSettlement::getVoidReason, "金蝶侧删单回调自动废除")
.set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED)
.set(FormalSettlement::getKingdeeBillNo, null)
.set(FormalSettlement::getKingdeeBillId, null)
.set(FormalSettlement::getSyncedTime, null));
// 变更流水:回调无登录态,操作人取金蝶报文(兜底 system,与 saveChange 同款)
FormalSettlementChangeRecord record = new FormalSettlementChangeRecord();
record.setFormalSettlementId(id);
record.setChangeType("结算单基本信息");
record.setOperationType("调整");
record.setChangeContent("金蝶删单回调自动废除并还原同步信息(金蝶单据已删除,可还原重推)");
record.setChangeReason("金蝶操作人:" + Func.toStr(operator));
record.setOperatorName(Func.isEmpty(operator) ? "system" : operator);
record.setChangeTime(LocalDateTime.now());
changeRecordMapper.insert(record);
}
@Override
public String syncKingdee(Long id) {
// 单据级事务经自代理调用:单条失败只回滚自身
String billNo = SpringUtil.getBean(IFormalSettlementService.class).doSyncKingdee(id);
// 主单事务提交后锁定金蝶单据(lockBusiData):失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态
lockKingdeeBillAfterPush(id, billNo);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return billNo;
}
/**
* 金蝶单据锁定(lockBusiData):A102-2/A302-2 推送成功事务提交后调用,目标单据类型按结算类型路由;
* 锁定异常独立捕获,不影响主单同步结果
*
* @param id 正式结算单id
* @param billNo 金蝶单据编号
*/
private void lockKingdeeBillAfterPush(Long id, String billNo) {
try {
if (Func.isBlank(billNo)) {
return;
}
FormalSettlement settlement = getById(id);
if (settlement == null) {
return;
}
kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_SETTLEMENT, id, settlement.getFormalSettlementNo(),
"receivable".equals(settlement.getSettlementType())
? KingdeeLockConstant.TARGET_AR_BILL : KingdeeLockConstant.TARGET_AP_BILL,
Func.toStrList(billNo));
} catch (Exception exception) {
log.error("正式结算单[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception);
}
}
/**
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,以本地 kingdeeBillId 定位金蝶单据;
* 本地缺失(存量数据/幂等跳过路径未带回内码)时经 A102-4/A302-4 按防重键反查回填;
@@ -55,6 +55,7 @@ import org.springblade.thirdparty.bfi.service.IBfiService;
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.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
@@ -93,6 +94,7 @@ import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO;
import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO;
import org.springblade.transport.pojo.vo.InvoiceApplicationVO;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
@@ -157,6 +159,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
/** 金蝶单据锁定记录服务(lockBusiData 锁定/补偿/解锁守卫) */
private final IKingdeeBillLockService kingdeeBillLockService;
/**
* 金蝶开票结果回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写),
* 否则租约到期后另一副本会并发起跑;进程异常退出时锁在租约到期后自动释放
@@ -498,6 +503,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public void voidBill(InvoiceApplicationStatusRequest request) {
InvoiceApplication entity = existing(request.getId());
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许作废");
// 金蝶锁定中禁止作废(lockBusiData 已锁单,金蝶侧未解锁前单据不可改动)
kingdeeBillLockService.checkEditable(KingdeeLockConstant.SOURCE_INVOICE_APPLY, request.getId());
entity.setApprovalStatus(TransportBusiConstant.VOIDED);
entity.setCurrentNode("已作废");
entity.setCurrentProcessor(AuthUtil.getUserName());
@@ -507,15 +514,69 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
relationSettlementIds(entity.getId()).forEach(this::refreshSettlementInvoiceStatus);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void voidByKingdee(Long id, String operator) {
InvoiceApplication entity = existing(id);
log.info("开票申请[{}]金蝶删单回调作废:金蝶操作人={}", entity.getApplicationNo(), operator);
// 作废并还原同步信息:updateById 忽略 null 字段,清空字段须显式 set null(张次金蝶单号由重推覆盖,不清)
this.update(Wrappers.<InvoiceApplication>lambdaUpdate()
.eq(InvoiceApplication::getId, id)
.set(InvoiceApplication::getApprovalStatus, TransportBusiConstant.VOIDED)
.set(InvoiceApplication::getCurrentNode, "已作废")
.set(InvoiceApplication::getCurrentProcessor, operator)
.set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED)
.set(InvoiceApplication::getKingdeeBillNo, null)
.set(InvoiceApplication::getSyncedTime, null));
// 操作流水:回调无登录态,操作人取金蝶报文(兜底 system,与 record 同款)
InvoiceApplicationRecord voidRecord = new InvoiceApplicationRecord();
voidRecord.setInvoiceApplicationId(id);
voidRecord.setActionType("kingdee_delete");
voidRecord.setActionName("金蝶删单回调作废");
voidRecord.setFromStatus(TransportBusiConstant.APPROVED);
voidRecord.setToStatus(TransportBusiConstant.VOIDED);
voidRecord.setOperatorName(Func.isEmpty(operator) ? "system" : operator);
voidRecord.setReason("金蝶侧删单回调自动作废并还原同步信息(金蝶开票申请单已删除,可还原重推)");
recordMapper.insert(voidRecord);
// 关联数据还原:关联正式结算单开票状态刷新
relationSettlementIds(id).forEach(this::refreshSettlementInvoiceStatus);
}
@Override
public String syncKingdee(Long id) {
// 单据级事务经自代理调用:单条失败只回滚自身
String billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id);
// 主单事务提交后锁定金蝶开票单(lockBusiData):每张发票张次一张金蝶单,多张逗号拼接批量锁定;
// 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态
lockKingdeeBillAfterPush(id, billNo);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return billNo;
}
/**
* 金蝶开票单锁定(lockBusiData):A303-1 推送成功事务提交后调用,多张金蝶单批量锁定;
* 锁定异常独立捕获,不影响主单同步结果
*
* @param id 开票申请id
* @param billNo 金蝶开票单编号(多张逗号拼接)
*/
private void lockKingdeeBillAfterPush(Long id, String billNo) {
try {
if (Func.isBlank(billNo)) {
return;
}
InvoiceApplication entity = getById(id);
if (entity == null) {
return;
}
kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_INVOICE_APPLY, id, entity.getApplicationNo(),
KingdeeLockConstant.TARGET_INVOICE_BILL, Func.toStrList(billNo));
} catch (Exception exception) {
log.error("开票申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception);
}
}
/**
* 附件联带上传(A902):主单推送成功(含 A303-4 勾稽回填)后调用,以本地 kingdeeBillId 定位金蝶单据;
* 本地缺失(存量数据)跳过待重推补传;附件上传异常独立捕获,绝不影响主单同步结果
@@ -0,0 +1,306 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeLockConstant;
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.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
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.stream.Collectors;
/**
* 金蝶单据锁定记录服务实现
* <p>
* 锁定:推送链路事务提交后按源单分组批量 LOCK(每组一行到多行锁记录,先落 failed 再调 lockBusiData,
* 成功翻 locked),失败由补偿定时任务重试;解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量),
* DELETE 时分发废除源单并还原关联数据。
* 与结算单/开票申请服务互相依赖,为避免构造器循环依赖,单据服务经 {@code SpringUtil.getBean} 运行时获取
* (与本工程 doSyncKingdee 自代理同一惯用法)
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMapper, KingdeeBillLock> implements IKingdeeBillLockService {
/** 金蝶各接口统一日期文本格式(与 A102-2/A102-4/A103-1 等一致) */
private static final DateTimeFormatter BFI_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
private final FormalSettlementMapper formalSettlementMapper;
private final InvoiceApplicationMapper invoiceApplicationMapper;
@Override
public void lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos) {
if (Func.isEmpty(targetBillNos)) {
return;
}
String operator = operatorName();
// 待锁目标单:已 locked 幂等跳过(人工重推「同步金蝶」幂等命中路径不会重复锁);
// 其余(新目标单/failed 重锁/同单重推)先落 failed 行占位,保证补偿扫表无遗漏
List<KingdeeBillLock> pendingRecords = new ArrayList<>();
for (String targetBillNo : targetBillNos.stream().filter(Func::isNotBlank).distinct().toList()) {
KingdeeBillLock lockRecord = latestRecord(sourceBillType, sourceBillId, targetBillNo);
if (lockRecord != null && KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
continue;
}
if (lockRecord == null || KingdeeLockConstant.STATUS_UNLOCKED.equals(lockRecord.getLockStatus())) {
lockRecord = new KingdeeBillLock();
lockRecord.setSourceBillType(sourceBillType);
lockRecord.setSourceBillId(sourceBillId);
lockRecord.setSourceBillNo(sourceBillNo);
lockRecord.setTargetBillType(targetBillType);
lockRecord.setTargetBillNo(targetBillNo);
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_FAILED);
lockRecord.setOperationType(KingdeeLockConstant.OPERATION_SAVE);
lockRecord.setTryCount(0);
lockRecord.setLockOperator(operator);
this.save(lockRecord);
}
pendingRecords.add(lockRecord);
}
if (pendingRecords.isEmpty()) {
return;
}
// 同源单目标单合并一次调用:编号逗号拼接批量传参,整批同成败(应答仅聚合 updateCount)
try {
bfiService.lockBusiData(this.buildLockParam(sourceBillType, sourceBillNo, targetBillType,
pendingRecords.stream().map(KingdeeBillLock::getTargetBillNo).toList(), operator));
LocalDateTime now = LocalDateTime.now();
for (KingdeeBillLock lockRecord : pendingRecords) {
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()));
} catch (Exception exception) {
String failReason = limitText(exception.getMessage(), 500);
for (KingdeeBillLock lockRecord : pendingRecords) {
lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1);
lockRecord.setFailReason(failReason);
this.updateById(lockRecord);
}
log.error("金蝶单据批量锁定失败(待补偿重锁):源单[{}:{}],金蝶单{}张", sourceBillType, sourceBillNo, pendingRecords.size(), exception);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public int onUnlockNotify(BfiBillLockDTO param) {
if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem())
|| !KingdeeLockConstant.SYSTEM_WLTMS.equals(param.getTargetSystem())
|| !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK");
}
if (Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法: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);
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
param.getTargetBillType(), targetBillNo);
continue;
}
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(param.getOperationType());
lockRecord.setUnlockTime(LocalDateTime.now());
lockRecord.setNotifyOperator(param.getOperator());
this.updateById(lockRecord);
processed++;
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;
}
/**
* 金蝶删单(DELETE 回调)分发:废除源单并还原关联数据(还原口径详见各 voidByKingdee 实现)
*
* @param lockRecord 锁记录(携带源单类型与源单主键)
* @param operator 金蝶侧操作人
*/
private void restoreOnKingdeeDelete(KingdeeBillLock lockRecord, String operator) {
String sourceType = KingdeeLockConstant.TARGET_TO_SOURCE_MAP.get(lockRecord.getTargetBillType());
switch (sourceType == null ? "" : sourceType) {
case KingdeeLockConstant.SOURCE_SETTLEMENT -> {
FormalSettlement settlement = formalSettlementMapper.selectById(lockRecord.getSourceBillId());
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
log.warn("金蝶删单回调源结算单不存在:id={}", lockRecord.getSourceBillId());
return;
}
if (TransportBusiConstant.VOIDED.equals(settlement.getApprovalStatus())) {
log.info("金蝶删单回调源结算单[{}]已作废(幂等跳过)", settlement.getFormalSettlementNo());
return;
}
SpringUtil.getBean(IFormalSettlementService.class).voidByKingdee(settlement.getId(), operator);
}
case KingdeeLockConstant.SOURCE_INVOICE_APPLY -> {
InvoiceApplication application = invoiceApplicationMapper.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.getApplicationNo());
return;
}
SpringUtil.getBean(IInvoiceApplicationService.class).voidByKingdee(application.getId(), operator);
}
default -> log.warn("金蝶删单回调暂不支持的单据类型:targetBillType={}(映射预留)", lockRecord.getTargetBillType());
}
}
@Override
public void checkEditable(String sourceBillType, Long sourceBillId) {
long lockedCount = this.count(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
if (lockedCount > 0) {
throw new ServiceException("该单据已被金蝶锁定,不可修改");
}
}
@Override
public String retryFailedLocks() {
List<KingdeeBillLock> failedList = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_FAILED)
.lt(KingdeeBillLock::getTryCount, KingdeeLockConstant.RETRY_MAX_COUNT));
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);
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();
} 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);
}
}
return "待补偿" + failedList.size() + "张,成功" + success + "张";
}
/**
* 组装 lockBusiData 参数:WLTMS 调金蝶,源系统取配置(默认 WLTMS),目标系统 XH,
* 操作类型固定 LOCK/SAVE,多张目标单编号逗号拼接批量传参
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillNo WLTMS 单号
* @param targetBillType 金蝶目标单据类型
* @param targetBillNos 金蝶单据编号集合
* @param operator 操作人
* @return 锁单参数
*/
private BfiBillLockDTO buildLockParam(String sourceBillType, String sourceBillNo, String targetBillType, List<String> targetBillNos, String operator) {
BfiBillLockDTO param = new BfiBillLockDTO();
param.setLockAction(KingdeeLockConstant.ACTION_LOCK);
param.setSourceSystem(Func.isBlank(bfiProperties.getSrcSys()) ? KingdeeLockConstant.SYSTEM_WLTMS : bfiProperties.getSrcSys());
param.setTargetSystem(KingdeeLockConstant.SYSTEM_XH);
param.setOperationType(KingdeeLockConstant.OPERATION_SAVE);
param.setSourceBillType(sourceBillType);
param.setSourceBillNo(sourceBillNo);
param.setTargetBillType(targetBillType);
param.setTargetBillNo(String.join(",", targetBillNos));
param.setTime(LocalDateTime.now().format(BFI_DATE_TIME));
param.setOperator(operator);
return param;
}
/**
* 查同源单同目标单最新一条锁记录
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillId WLTMS 业务主键
* @param targetBillNo 金蝶单据编号
* @return 最新锁记录,无则 null
*/
private KingdeeBillLock latestRecord(String sourceBillType, Long sourceBillId, String targetBillNo) {
return this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
.orderByDesc(KingdeeBillLock::getId)
.last("limit 1"), false);
}
/** 当前操作人:无登录态(定时任务/回调)时兜底 system(与 saveChange 同款兜底) */
private String operatorName() {
return Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName();
}
/** 失败原因文本截断(fail_reason 列宽 500) */
private String limitText(String text, int maxLength) {
if (Func.isBlank(text)) {
return null;
}
return text.length() <= maxLength ? text : text.substring(0, maxLength);
}
}
@@ -0,0 +1,53 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 金蝶单据锁定补偿定时任务
* <p>
* 扫描锁定失败的锁记录(lock_status=failed 且未超重试上限)重调 lockBusiData,
* 补偿推送成功但锁单失败的单据,不依赖人工重推「同步金蝶」。
* 业务逻辑收敛在 {@link IKingdeeBillLockService#retryFailedLocks()},任务类只做开关与调度(模式同 PaymentKingdeeResultTask)。
* <p>
* 调度配置(Nacos thirdParty.bfi.billLock.*):retryEnabled 开关、retryCron 表达式。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KingdeeBillLockRetryTask {
private final IKingdeeBillLockService kingdeeBillLockService;
/** 锁定补偿开关,代码默认关闭(安全兜底),由 Nacos thirdParty.bfi.billLock.retryEnabled 按环境显式开启 */
@Value("${thirdParty.bfi.billLock.retryEnabled:false}")
private boolean retryEnabled;
/** 锁定补偿调度,默认每5分钟执行一次 */
@Scheduled(cron = "${thirdParty.bfi.billLock.retryCron:0 */5 * * * ?}")
public void retryFailedBillLocks() {
if (!retryEnabled) {
return;
}
try {
String summary = kingdeeBillLockService.retryFailedLocks();
log.info("定时补偿金蝶单据锁定:{}", summary);
} catch (Exception exception) {
log.error("定时补偿金蝶单据锁定异常", exception);
}
}
}
@@ -0,0 +1,32 @@
-- 金蝶单据锁定记录表:lockBusiData 锁定 / unLockNotifyBusiData 解锁回调 / 锁定补偿
-- 一行 = 一张金蝶目标单:锁定留痕(人/时间/失败原因)+ 当前锁状态(编辑守卫/回调定位/补偿扫表)
-- lock_status:failed-锁定失败(补偿重试对象)/ locked-金蝶锁定中 / unlocked-已解锁(终态)
CREATE TABLE `blade_kingdee_bill_lock` (
`id` bigint NOT NULL COMMENT '主键',
`tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID',
`source_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单据类型:SETTLEMENT正式结算单/INVOICE_APPLY开票申请单(预留PAY_APPLY付款申请单/RECEIVED_INVOICE收票单)',
`source_bill_id` bigint NOT NULL COMMENT 'WLTMS业务主键',
`source_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单号',
`target_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据类型:ApFin_other_BT_S应付单/arfin_other_BT_S应收单/cdm_paybill_bank_elect_BT_S开票单等固定值',
`target_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据编号(开票申请按发票张次逐张一行)',
`lock_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '锁状态:failed锁定失败/locked金蝶锁定中/unlocked已解锁',
`operation_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最近动作来源操作类型:SAVE单据新增/DELETE单据删除',
`try_count` int DEFAULT '0' COMMENT '锁定重试次数(补偿任务用,超上限停试)',
`lock_time` datetime DEFAULT NULL COMMENT '锁定时间',
`unlock_time` datetime DEFAULT NULL COMMENT '解锁时间',
`lock_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推单发起人(锁定操作人)',
`notify_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调操作人(金蝶侧操作人)',
`fail_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '锁定失败原因',
`create_user` bigint DEFAULT NULL COMMENT '创建人',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_user` bigint DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`status` int DEFAULT NULL COMMENT '状态',
`is_deleted` int DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
INDEX `idx_kbl_source` (`source_bill_type`, `source_bill_id`),
INDEX `idx_kbl_target` (`target_bill_type`, `target_bill_no`),
INDEX `idx_kbl_status` (`lock_status`, `try_count`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '金蝶单据锁定记录' ROW_FORMAT = DYNAMIC;