Merge remote-tracking branch 'origin/feature/kingdee-attachment-sync' into feature/payment-apply-bfi-sync

# Conflicts:
#	blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
This commit is contained in:
刘泉佳
2026-09-28 10:50:09 +08:00
35 changed files with 2452 additions and 38 deletions
@@ -0,0 +1,176 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
import org.springblade.core.log.exception.ServiceException;
/**
* 金蝶附件同步(A902)常量
* <p>entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构</p>
*
* @author Chill
*/
public class KingdeeAttachmentConstant {
// ==================== 业务单据类型(biz_type) ====================
/**
* 付款申请单
*/
public static final String BIZ_PAY_APPLICATION = "pay_application";
/**
* 正式结算-财务应付单面板
*/
public static final String BIZ_FORMAL_SETTLEMENT_AP = "formal_settlement_ap";
/**
* 正式结算-财务应收单面板
*/
public static final String BIZ_FORMAL_SETTLEMENT_AR = "formal_settlement_ar";
/**
* 收票单
*/
public static final String BIZ_INVOICE_RECEIPT = "invoice_receipt";
/**
* 开票申请单
*/
public static final String BIZ_INVOICE_APPLICATION = "invoice_application";
// ==================== 星瀚 entityNumber(A902 定位单据类型) ====================
/**
* 付款申请单
*/
public static final String ENTITY_PAY_APPLICATION = "ap_payapply";
/**
* 财务应付单(正式结算应付面板)
*/
public static final String ENTITY_AP_BILL = "ap_finapbill";
/**
* 财务应收单(正式结算应收面板)
*/
public static final String ENTITY_AR_BILL = "ar_finarbill";
/**
* 收票单
*/
public static final String ENTITY_INVOICE = "ap_invoice";
/**
* 开票申请单
*/
public static final String ENTITY_INVOICE_APPLICATION = "sim_original_bill";
/**
* 业务单据类型 -> 星瀚 entityNumber 映射;
* 客商(资质附件)二期接入:待金蝶确认后补充专用 biz_type 与 entityNumber 条目
*/
public static final Map<String, String> ENTITY_NUMBERS = Map.of(
BIZ_PAY_APPLICATION, ENTITY_PAY_APPLICATION,
BIZ_FORMAL_SETTLEMENT_AP, ENTITY_AP_BILL,
BIZ_FORMAL_SETTLEMENT_AR, ENTITY_AR_BILL,
BIZ_INVOICE_RECEIPT, ENTITY_INVOICE,
BIZ_INVOICE_APPLICATION, ENTITY_INVOICE_APPLICATION
);
/**
* 星瀚附件面板/附件字段标识(各单据统一)
*/
public static final String CONTROL_KEY = "attachmentpanel";
// ==================== 限制 ====================
/**
* 单文件软限制 20MB(待金蝶确认后调整);超限记失败原因跳过,不阻塞同单其余附件
*/
public static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024;
// ==================== 同步行状态(sync_status) ====================
/**
* 同步失败
*/
public static final int SYNC_STATUS_FAILED = 0;
/**
* 同步成功(终态,幂等跳过)
*/
public static final int SYNC_STATUS_SUCCESS = 1;
// ==================== 附件同步聚合态(列表展示,运行时聚合非存储字段) ====================
/**
* 无附件:单据 attachmentsJson 无可上传附件
*/
public static final String AGGREGATE_NONE = "none";
/**
* 未上传:有附件且成功数为 0
*/
public static final String AGGREGATE_PENDING = "pending";
/**
* 部分未传:有附件且部分上传成功
*/
public static final String AGGREGATE_PARTIAL = "partial";
/**
* 已上传:有附件且全部上传成功
*/
public static final String AGGREGATE_DONE = "done";
/**
* 聚合态 -> 展示名称
*/
public static final Map<String, String> AGGREGATE_NAMES = Map.of(
AGGREGATE_NONE, "无附件",
AGGREGATE_PENDING, "未上传",
AGGREGATE_PARTIAL, "部分未传",
AGGREGATE_DONE, "已上传"
);
/**
* 业务单据类型对应星瀚 entityNumber
*
* @param bizType 业务单据类型
* @return entityNumber,未配置时抛业务异常(entityNumber 是硬约束,猜错即传不上)
*/
public static String entityNumber(String bizType) {
String entityNumber = ENTITY_NUMBERS.get(bizType);
if (entityNumber == null) {
throw new ServiceException("金蝶附件上传失败:业务单据类型[" + bizType + "]未配置 entityNumber 映射");
}
return entityNumber;
}
/**
* 聚合态展示名称(未知态兜底返回原文)
*
* @param aggregateStatus 聚合态
* @return 展示名称
*/
public static String aggregateName(String aggregateStatus) {
return AGGREGATE_NAMES.getOrDefault(aggregateStatus, aggregateStatus);
}
private KingdeeAttachmentConstant() {
}
}
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
@@ -86,6 +87,14 @@ public class InvoiceReceiptController extends BladeController {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@GetMapping("/invoice-pool/attachment/download")
@ApiOperationSupport(order = 17)
@Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)",
description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误")
public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) {
invoiceReceiptService.downloadPoolAttachment(invoiceNo, response);
}
@GetMapping("/settlement-candidates")
@ApiOperationSupport(order = 4)
@Operation(summary = "可关联的应付正式结算单")
@@ -0,0 +1,39 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
/**
* 金蝶附件同步 Mapper
*
* @author Chill
*/
@Mapper
public interface KingdeeAttachmentSyncMapper extends BaseMapper<KingdeeAttachmentSync> {
}
@@ -40,6 +40,16 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
void returnBill(FormalSettlementStatusRequest request);
void voidBill(FormalSettlementStatusRequest request);
String syncKingdee(Long id);
/**
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
* A102-2/A302-2 保存并回写结算单单据编号与星瀚内码(kingdeeBillId)
*
* @param id 正式结算单id
* @return 金蝶单据编号
*/
String doSyncKingdee(Long id);
String syncKingdeeArResult();
List<FormalSettlementDetailFee> detailFees(Long detailId);
void adjustDetail(PreSettlementDetailAdjustRequest request);
@@ -55,5 +55,15 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
void returnBill(InvoiceApplicationStatusRequest request);
void voidBill(InvoiceApplicationStatusRequest request);
String syncKingdee(Long id);
/**
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
* A303-1 保存并回写开票申请单单据编号与星瀚内码(kingdeeBillId)
*
* @param id 开票申请id
* @return 金蝶开票申请单编号
*/
String doSyncKingdee(Long id);
String syncKingdeeInvoiceResult();
}
@@ -26,6 +26,7 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -58,6 +59,17 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
*/
KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
/**
* 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。
* <p>按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
* 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器,
* 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。
*
* @param invoiceNo 发票号码
* @param response 响应对象(文件流写出口)
*/
void downloadPoolAttachment(String invoiceNo, HttpServletResponse response);
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
Map<String, Object> referenceInformation(String settlementIds);
@@ -0,0 +1,37 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
/**
* 金蝶附件同步服务
*
* @author Chill
*/
public interface IKingdeeAttachmentSyncService extends BaseService<KingdeeAttachmentSync> {
}
@@ -36,6 +36,7 @@ import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper;
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.TransportBusiConstant;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
@@ -81,12 +82,14 @@ import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.transport.wrapper.FormalSettlementWrapper;
import org.springblade.transport.wrapper.PaymentApplicationWrapper;
import org.springblade.system.cache.UserCache;
import org.springframework.beans.factory.annotation.Value;
import org.springblade.core.tool.utils.SpringUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -144,6 +147,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private final IWaybillService waybillService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
/**
@@ -180,7 +184,35 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
if (ids.isEmpty()) wrapper.eq(FormalSettlement::getId, -1L); else wrapper.in(FormalSettlement::getId, ids);
}
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
return result.convert(this::toVO);
IPage<FormalSettlementVO> voPage = result.convert(this::toVO);
fillAttachmentSyncStatus(voPage.getRecords());
return voPage;
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1
*
* @param rows 本页 VO 列表
*/
private void fillAttachmentSyncStatus(List<FormalSettlementVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, Integer> apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream()
.filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
Map<Long, Integer> arSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR, rows.stream()
.filter(row -> "receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
for (FormalSettlementVO vo : rows) {
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
Map<Long, Integer> successCounts = "receivable".equals(vo.getSettlementType()) ? arSuccessCounts : apSuccessCounts;
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
successCounts.getOrDefault(vo.getId(), 0));
vo.setAttachmentCount(attachmentCount);
vo.setAttachmentSyncStatus(aggregateStatus);
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
}
}
@Override
@@ -488,11 +520,77 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
@Override
@Transactional(rollbackFor = Exception.class)
public String syncKingdee(Long id) {
// 单据级事务经自代理调用:单条失败只回滚自身
String billNo = SpringUtil.getBean(IFormalSettlementService.class).doSyncKingdee(id);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return billNo;
}
/**
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,以本地 kingdeeBillId 定位金蝶单据;
* 本地缺失(存量数据/幂等跳过路径未带回内码)时经 A102-4/A302-4 按防重键反查回填;
* 附件上传异常独立捕获,绝不影响主单同步结果
*
* @param id 正式结算单id
*/
private void syncAttachmentsAfterPush(Long id) {
try {
FormalSettlement settlement = getById(id);
if (settlement == null || !"synced".equals(settlement.getKingdeeSyncStatus())) {
return;
}
String billPkId = settlement.getKingdeeBillId();
if (Func.isBlank(billPkId)) {
billPkId = resolveKingdeeBillId(settlement);
if (Func.isNotBlank(billPkId)) {
log.info("正式结算单[{}]经 A102-4/A302-4 反查回填星瀚单据内码:{}", settlement.getFormalSettlementNo(), billPkId);
FormalSettlement patch = new FormalSettlement();
patch.setId(settlement.getId());
patch.setKingdeeBillId(billPkId);
updateById(patch);
}
}
if (Func.isBlank(billPkId)) {
log.warn("正式结算单[{}]金蝶附件联带上传跳过:星瀚单据内码未落库且反查未命中,待重推补传",
settlement.getFormalSettlementNo());
return;
}
String bizType = "receivable".equals(settlement.getSettlementType())
? KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR : KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP;
kingdeeAttachmentSyncSupport.syncUserAttachments(bizType, id, billPkId, settlement.getAttachmentsJson());
} catch (Exception exception) {
log.error("正式结算单[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
}
}
/**
* 反查星瀚单据内码:按结算类型组装防重键(与 A102-2/A302-2 传参一致)后调用对应查询接口
*
* @param settlement 正式结算单
* @return 星瀚单据内码,未查得返回 null
*/
private String resolveKingdeeBillId(FormalSettlement settlement) {
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
return null;
}
if ("receivable".equals(settlement.getSettlementType())) {
return kingdeeArBillSyncSupport.queryArBillExistId(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
}
return kingdeeApBillSyncSupport.queryApBillExistId(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
}
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
FormalSettlement settlement = existing(id);
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据)
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填
if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo();
// 流程拆分:正式结算单「同步金蝶」负责推送财务应付单(A102-2)并回写结算单单据编号;
// 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推),推送前经 A102-4 预检本单应付单已生成
@@ -506,6 +604,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
KingdeeSyncContext context = KingdeeSyncContext.empty();
// 单据类型路由:应付对应财务应付单(A102-2),应收对应财务应收单(A302-2),两链路同一套幂等预检与推送逻辑
String billNo;
String billPkId;
String changeDesc;
if ("receivable".equals(settlement.getSettlementType())) {
// 应收结算:财务应收单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接
@@ -514,6 +613,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeArBillSyncSupport.pushArBill(
arBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context);
billNo = outcome.billNo();
billPkId = outcome.billId();
changeDesc = "同步金蝶财务应收单" + billNo;
} else {
// 应付结算:财务应付单防重键,付款申请「同步金蝶」(A103-1 应付下推)推送前按该键经 A102-4 预检本单应付单已生成
@@ -523,14 +623,19 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill(
apBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context);
billNo = outcome.billNo();
billPkId = outcome.billId();
changeDesc = "同步金蝶财务应付单" + billNo;
}
// 回写金蝶单据编号(A102-2/A302-2 单据编号)与同步状态;付款申请侧回填的付款申请单编号(A103-2)不是同一单据
// 回写金蝶单据编号(A102-2/A302-2 单据编号)、星瀚单据内码(A902 附件上传定位用)与同步状态;
// 付款申请侧回填的付款申请单编号(A103-2)不是同一单据
settlement.setKingdeeBillNo(billNo);
settlement.setKingdeeBillId(billPkId);
settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
settlement.setSyncedTime(LocalDateTime.now());
updateById(settlement);
saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, "");
log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}",
settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId);
return billNo;
}
@@ -52,6 +52,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
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.TransportBusiConstant;
import org.springblade.transport.mapper.ContractManageMapper;
@@ -94,8 +95,10 @@ import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.wrapper.InvoiceApplicationWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springblade.core.tool.utils.SpringUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -149,6 +152,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private final BfiProperties bfiProperties;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
/**
@@ -167,7 +171,30 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
.eq(Func.isNotEmpty(query.getKingdeeStatus()), InvoiceApplication::getKingdeeStatus, query.getKingdeeStatus())
.eq(Func.isNotEmpty(query.getApprovalStatus()), InvoiceApplication::getApprovalStatus, query.getApprovalStatus())
.orderByDesc(InvoiceApplication::getCreateTime);
return page(page, wrapper).convert(this::toListVO);
IPage<InvoiceApplicationVO> voPage = page(page, wrapper).convert(this::toListVO);
fillAttachmentSyncStatus(voPage.getRecords());
return voPage;
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
*
* @param rows 本页 VO 列表
*/
private void fillAttachmentSyncStatus(List<InvoiceApplicationVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, rows.stream().map(InvoiceApplicationVO::getId).toList());
for (InvoiceApplicationVO vo : rows) {
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
successCounts.getOrDefault(vo.getId(), 0));
vo.setAttachmentCount(attachmentCount);
vo.setAttachmentSyncStatus(aggregateStatus);
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
}
}
@Override
@@ -479,11 +506,47 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
@Override
@Transactional(rollbackFor = Exception.class)
public String syncKingdee(Long id) {
// 单据级事务经自代理调用:单条失败只回滚自身
String billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id);
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return billNo;
}
/**
* 附件联带上传(A902):主单推送成功(含 A303-4 勾稽回填)后调用,以本地 kingdeeBillId 定位金蝶单据;
* 本地缺失(存量数据)跳过待重推补传;附件上传异常独立捕获,绝不影响主单同步结果
*
* @param id 开票申请id
*/
private void syncAttachmentsAfterPush(Long id) {
try {
InvoiceApplication entity = getById(id);
if (entity == null || Func.isBlank(entity.getKingdeeBillId())) {
log.warn("开票申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", entity == null ? id : entity.getApplicationNo());
return;
}
KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncUserAttachments(
KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
if (summary.total() > 0) {
// 附件同步操作流水(Q5 决策:开票申请有 actionType 流水表,追加 attachment_upload 记录)
record(id, "attachment_upload", "附件上传金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(),
summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons())
: "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)",
entity.getKingdeeBillNo());
}
} catch (Exception exception) {
log.error("开票申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
InvoiceApplication entity = existing(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态)
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) {
return entity.getKingdeeBillNo();
@@ -545,6 +608,16 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
if (existApply != null && Func.isNotBlank(existApply.getBillno())
&& sheetBillNos.contains(existApply.getBillno())) {
existSheetBillNos.add(existApply.getBillno());
// 金蝶已生成:直接回填视为同步成功(勾稽纠偏),星瀚内码一并回填(A902 附件上传定位用)
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),跳过重复推送直接回填",
entity.getApplicationNo(), existApply.getBillno(), existApply.getId());
entity.setKingdeeBillNo(existApply.getBillno());
entity.setKingdeeBillId(existApply.getId() == null ? null : String.valueOf(existApply.getId()));
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, existApply.getBillno());
return existApply.getBillno();
}
}
}
@@ -28,14 +28,18 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import feign.Response;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO;
@@ -44,6 +48,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.CustomerArchiveMapper;
@@ -74,6 +79,7 @@ import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.support.KingdeeReceivedInvoiceSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
@@ -81,12 +87,18 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -111,6 +123,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/** A801-1 rim 契约单页最大条数 */
private static final int POOL_PAGE_SIZE = 500;
/** A801-2 文件下载流式拷贝缓冲区大小 */
private static final int DOWNLOAD_BUFFER_SIZE = 8 * 1024;
private final KingdeeInvoicePoolMapper invoicePoolMapper;
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
private final InvoiceReceiptRecordMapper recordMapper;
@@ -123,6 +138,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final IBfiService bfiService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
/** 金蝶收票单结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
@@ -155,7 +171,30 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
.eq(Func.isNotEmpty(query.getKingdeePushStatus()), InvoiceReceipt::getKingdeePushStatus,
query.getKingdeePushStatus())
.orderByDesc(InvoiceReceipt::getCreateTime);
return page(page, wrapper).convert(this::toListVO);
IPage<InvoiceReceiptVO> voPage = page(page, wrapper).convert(this::toListVO);
fillAttachmentSyncStatus(voPage.getRecords());
return voPage;
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
*
* @param rows 本页 VO 列表
*/
private void fillAttachmentSyncStatus(List<InvoiceReceiptVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, rows.stream().map(InvoiceReceiptVO::getId).toList());
for (InvoiceReceiptVO vo : rows) {
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
successCounts.getOrDefault(vo.getId(), 0));
vo.setAttachmentCount(attachmentCount);
vo.setAttachmentSyncStatus(aggregateStatus);
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
}
}
@Override
@@ -707,6 +746,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
+ "未生成财务应付单");
}
String billNo;
String billPkId;
BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId()));
if (kingdeeRow != null) {
if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(kingdeeRow.getInvoicestatus())) {
@@ -715,11 +755,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
+ "),请先在金蝶处理后再重推");
log.warn("收票登记[{}]推送金蝶收票单拦截:{},金蝶单据编号={}", receipt.getInvoiceNo(), abnormalReason,
kingdeeRow.getBillno());
applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
abnormalReason, true);
applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(),
kingdeeRow.getId() == null ? null : String.valueOf(kingdeeRow.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED, abnormalReason, true);
throw new ServiceException(abnormalReason);
}
billNo = kingdeeRow.getBillno();
// 幂等预检命中行携带星瀚收票单内码,回填补齐(A902 附件上传定位用)
billPkId = kingdeeRow.getId() == null ? receipt.getKingdeeReceivedInvoiceId()
: String.valueOf(kingdeeRow.getId());
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo);
} else {
try {
@@ -734,32 +778,70 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups =
kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context);
billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}]", receipt.getInvoiceNo(), billNo);
KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome =
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
billNo = pushOutcome.billNo();
billPkId = pushOutcome.billId();
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId);
} catch (RuntimeException pushException) {
log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage());
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(),
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
throw pushException;
}
}
// 保存成功先落单号+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
// 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
// A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传):
// 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传
syncAttachmentsAfterPush(receipt, plan, billPkId);
try {
log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode);
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
} catch (RuntimeException assignException) {
log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage());
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true);
throw assignException;
}
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo);
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
}
/**
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
*
* @param receipt 收票登记
* @param plan 推送计划(镜像附件取数来源)
* @param billPkId 星瀚收票单内码
*/
private void syncAttachmentsAfterPush(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan, String billPkId) {
try {
if (Func.isBlank(billPkId)) {
log.warn("收票登记[{}]金蝶附件联带上传跳过:星瀚收票单内码未落库,待重推补传", receipt.getInvoiceNo());
return;
}
List<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> items = new ArrayList<>();
items.addAll(kingdeeAttachmentSyncSupport.parseUserAttachments(receipt.getAttachmentsJson()));
// 镜像发票文件:幂等键=path,经 A801-2 下载后 A902 回传收票单(path 变化视为新文件自动重传)
items.addAll(kingdeeAttachmentSyncSupport.parseInvoicePoolFile(plan.mirror().getAttachmentsJson()));
KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncAttachments(
KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, receipt.getId(), billPkId, items);
if (summary.total() > 0 || summary.skipCount() > 0) {
// 附件同步操作流水(Q5 决策:收票登记有 actionType 流水表,追加 attachment_upload 记录)
record(receipt.getId(), "attachment_upload", "附件上传金蝶", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons())
: "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)",
receipt.getKingdeeReceivedInvoiceNo());
}
} catch (Exception exception) {
log.error("收票登记[{}]金蝶附件联带上传异常(不影响推送状态,重推自动补传)", receipt.getInvoiceNo(), exception);
}
}
/**
* 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因)
*/
@@ -827,11 +909,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
/**
* 推送/回写结果落库:回写金蝶收票单号、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
*/
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String pushStatus,
String pushMsg, boolean writeRecord) {
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String receivedInvoiceId,
String pushStatus, String pushMsg, boolean writeRecord) {
receipt.setKingdeeReceivedInvoiceNo(receivedInvoiceNo);
if (Func.isNotBlank(receivedInvoiceId)) {
receipt.setKingdeeReceivedInvoiceId(receivedInvoiceId);
}
receipt.setKingdeePushStatus(pushStatus);
receipt.setKingdeePushMsg(pushMsg);
receipt.setKingdeePushTime(LocalDateTime.now());
@@ -941,7 +1026,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
try {
if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(row.getInvoicestatus())) {
applyReceivedInvoicePushResult(receipt, Func.toStrWithEmpty(row.getBillno(),
receipt.getKingdeeReceivedInvoiceNo()), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
receipt.getKingdeeReceivedInvoiceNo()),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("金蝶侧收票单发票状态异常(" + Func.toStrWithEmpty(row.getInvoicestatusTitle(),
row.getInvoicestatus()) + "),已回收推送状态,请在金蝶删除/解除指定后重推"), true);
recovered++;
@@ -955,6 +1042,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
// assigned 终态仅补登缺失的金蝶收票单号(保存成功但响应丢失的历史数据)
if (StringUtil.isBlank(receipt.getKingdeeReceivedInvoiceNo()) && Func.isNotBlank(row.getBillno())) {
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, false);
filled++;
}
@@ -975,18 +1063,24 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */
private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) {
if (StringUtil.isBlank(apBillSrcCode)) {
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true);
throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付");
}
try {
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
} catch (RuntimeException assignException) {
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("收票单指定应付补偿失败:" + assignException.getMessage()), true);
throw assignException;
}
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
applyReceivedInvoicePushResult(receipt, row.getBillno(),
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
}
/** 回写补偿用:批量组装 pushed 单据的财务应付单防重键(不满足一票一应付的登记不返回该键) */
@@ -1144,6 +1238,138 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return JsonUtil.toJson(attachment);
}
// ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ====================
@Override
public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) {
String trimmedInvoiceNo = trimOrNull(invoiceNo);
if (StringUtil.isBlank(trimmedInvoiceNo)) {
WebUtil.renderJson(response, R.fail("发票号不能为空"));
return;
}
try {
doDownloadPoolAttachment(trimmedInvoiceNo, response);
} catch (ServiceException exception) {
// 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件
log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage());
WebUtil.renderJson(response, R.fail(exception.getMessage()));
} catch (IOException exception) {
// 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志
log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception);
}
}
/**
* 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流
* → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆
*/
private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException {
KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo);
if (item == null || Func.isBlank(item.key())) {
throw new ServiceException("该发票没有金蝶侧文件");
}
long start = System.currentTimeMillis();
Response bfiResponse = bfiService.downloadInvoice(item.key());
try {
String contentType = firstHeader(bfiResponse, "Content-Type");
// A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流
if (contentType != null && contentType.contains("application/json")) {
throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse));
}
String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name();
// 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码
String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType);
String contentLength = firstHeader(bfiResponse, "Content-Length");
if (contentLength != null) {
try {
response.setContentLengthLong(Long.parseLong(contentLength));
} catch (NumberFormatException ignored) {
// 非法长度头按无长度处理,走分块传输
}
}
response.setHeader("Content-Disposition",
"attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);
int totalBytes = 0;
try (InputStream inputStream = bfiResponse.body().asInputStream();
OutputStream outputStream = response.getOutputStream()) {
byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
totalBytes += read;
}
outputStream.flush();
}
log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms",
invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start);
} finally {
bfiResponse.close();
}
}
/**
* 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
* 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl;
* 远程票号查询为模糊匹配,命中行需与目标票号全等防御
*/
private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) {
List<KingdeeInvoicePool> mirrors = invoicePoolMapper.selectList(Wrappers.<KingdeeInvoicePool>lambdaQuery()
.eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo));
if (Func.isNotEmpty(mirrors)) {
Optional<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> mirrorItem = kingdeeAttachmentSyncSupport
.parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst();
if (mirrorItem.isPresent()) {
return mirrorItem.get();
}
}
KingdeeInvoicePoolQueryDTO query = new KingdeeInvoicePoolQueryDTO();
query.setInvoiceNo(invoiceNo);
List<BfiInvoicePoolVO> rows = queryPoolPage(1, POOL_PAGE_SIZE, query);
for (BfiInvoicePoolVO row : rows) {
if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) {
return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row))
.stream().findFirst().orElse(null);
}
}
return null;
}
/** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */
private String readBfiJsonMessage(Response response) {
try (InputStream inputStream = response.body().asInputStream()) {
Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class);
if (parsed instanceof Map<?, ?> map) {
String message = firstTextValue(map, "message");
if (Func.isNotBlank(message)) {
return message;
}
String errorCode = firstTextValue(map, "errorCode");
if (Func.isNotBlank(errorCode)) {
return errorCode;
}
}
} catch (Exception exception) {
log.warn("解析BFI文件下载错误响应失败", exception);
}
return "响应体为JSON错误";
}
/** 大小写不敏感取第一个响应头值 */
private String firstHeader(Response response, String name) {
for (Map.Entry<String, Collection<String>> entry : response.headers().entrySet()) {
if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) {
return entry.getValue().iterator().next();
}
}
return null;
}
private String firstTextValue(Map<?, ?> map, String key) {
Object value = map.get(key);
return value == null ? null : String.valueOf(value);
}
/**
* 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束)
*/
@@ -0,0 +1,44 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper;
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
import org.springblade.transport.service.IKingdeeAttachmentSyncService;
import org.springframework.stereotype.Service;
/**
* 金蝶附件同步服务实现
*
* @author Chill
*/
@Slf4j
@Service
public class KingdeeAttachmentSyncServiceImpl extends BaseServiceImpl<KingdeeAttachmentSyncMapper, KingdeeAttachmentSync>
implements IKingdeeAttachmentSyncService {
}
@@ -47,13 +47,13 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.BillLedgerMapper;
import org.springblade.transport.mapper.BillLedgerUsageMapper;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerReceiptAccountMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.FormalSettlementPaymentMapper;
@@ -95,6 +95,7 @@ import org.springblade.transport.service.IPaymentApplicationService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.wrapper.PaymentApplicationWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -140,13 +141,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private final ProjectApplyMapper projectApplyMapper;
private final ContractManageMapper contractManageMapper;
private final CustomerArchiveMapper customerArchiveMapper;
private final CustomerReceiptAccountMapper receiptAccountMapper;
private final CustomerReceiptAccountMapper receiptAccountMapper;
private final BillLedgerMapper billLedgerMapper;
private final BillLedgerUsageMapper billLedgerUsageMapper;
private final TemporaryCreditLimitMapper temporaryCreditLimitMapper;
private final IFormalSettlementService formalSettlementService;
private final IBfiService bfiService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final BfiProperties bfiProperties;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
@@ -171,11 +173,35 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
.ge(query.getApplyStartDate() != null, PaymentApplication::getApplyDate, query.getApplyStartDate())
.le(query.getApplyEndDate() != null, PaymentApplication::getApplyDate, query.getApplyEndDate())
.orderByDesc(PaymentApplication::getCreateTime);
return page(page, wrapper).convert(item -> {
IPage<PaymentApplicationVO> voPage = page(page, wrapper).convert(item -> {
PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(item);
if (!TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) vo.setPaidAmount(BigDecimal.ZERO);
return vo;
});
fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords());
return voPage;
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
*
* @param bizType 业务单据类型
* @param rows 本页 VO 列表
*/
private void fillAttachmentSyncStatus(String bizType, List<PaymentApplicationVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(bizType,
rows.stream().map(PaymentApplicationVO::getId).toList());
for (PaymentApplicationVO vo : rows) {
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
successCounts.getOrDefault(vo.getId(), 0));
vo.setAttachmentCount(attachmentCount);
vo.setAttachmentSyncStatus(aggregateStatus);
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
}
}
@Override
@@ -373,9 +399,10 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
@Override
public String syncKingdee(Long id, KingdeeSyncContext context) {
String kingdeeBillId;
try {
// 单据级事务经自代理调用:单条失败只回滚自身,避免批量同步共用大事务长持锁
return self().doSyncKingdee(id, context);
kingdeeBillId = self().doSyncKingdee(id, context);
} catch (ServiceException exception) {
log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception);
// 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态
@@ -386,6 +413,30 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
self().markKingdeeSyncFailed(id);
throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage());
}
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
syncAttachmentsAfterPush(id);
return kingdeeBillId;
}
/**
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,
* 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传;
* 附件上传异常独立捕获,绝不影响主单同步结果
*
* @param id 付款申请id
*/
private void syncAttachmentsAfterPush(Long id) {
try {
PaymentApplication entity = getById(id);
if (entity == null || Func.isBlank(entity.getKingdeeBillId())) {
log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id);
return;
}
kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
} catch (Exception exception) {
log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
}
}
@Override
@@ -17,6 +17,7 @@
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
@@ -171,6 +172,32 @@ public class KingdeeApBillSyncSupport {
return existBillNos;
}
/**
* A102-4 单防重键查询已生成财务应付单的星瀚内码(A902 附件上传定位用):
* 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填
*
* @param awrpSrcCode 财务应付单防重键
* @return 星瀚应付单内码,未生成返回 null
*/
public String queryApBillExistId(String awrpSrcCode) {
if (Func.isBlank(awrpSrcCode)) {
return null;
}
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
param.setAwrpSrcCode(List.of(awrpSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(PAY_RESULT_PAGE_SIZE);
return bfiService.queryApBill(param);
})) {
if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && apBill.getId() != null) {
return String.valueOf(apBill.getId());
}
}
return null;
}
// ==================== A102-2 财务应付单保存 ====================
/**
@@ -196,7 +223,7 @@ public class KingdeeApBillSyncSupport {
queryApBillExistNos(List.of(code)).getOrDefault(code, ""));
if (Func.isNotEmpty(existBillNo)) {
log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo);
return new BfiApBillPushOutcome(existBillNo, true);
return new BfiApBillPushOutcome(existBillNo, null, true);
}
ContractManage contract = requireContract(settlement.getContractId(), context);
// 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可
@@ -370,7 +397,9 @@ public class KingdeeApBillSyncSupport {
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
String billNo = saveResult.getResult().get(0).getNumber();
log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size());
return new BfiApBillPushOutcome(billNo, false);
// 星瀚应付单内码随结果返回(A902 附件上传定位用)
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
}
// ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
@@ -887,8 +916,9 @@ public class KingdeeApBillSyncSupport {
* 财务应付单(A102-2)推送结果
*
* @param billNo 财务应付单单据编号(金蝶已存在时返回已有单据编号)
* @param billId 财务应付单星瀚内码(A902 附件上传定位用;金蝶已存在幂等跳过时为空,经 A102-4 查询回填)
* @param alreadyExist 金蝶是否已存在该防重键的财务应付单,true 表示本次幂等跳过保存(上次同步已在后续环节中断)
*/
public record BfiApBillPushOutcome(String billNo, boolean alreadyExist) {
public record BfiApBillPushOutcome(String billNo, String billId, boolean alreadyExist) {
}
}
@@ -27,6 +27,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
@@ -111,6 +112,32 @@ public class KingdeeArBillSyncSupport {
return existBillNos;
}
/**
* A302-4 单防重键查询已生成财务应收单的星瀚内码(A902 附件上传定位用):
* 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填
*
* @param arBillSrcCode 财务应收单防重键
* @return 星瀚应收单内码,未生成返回 null
*/
public String queryArBillExistId(String arBillSrcCode) {
if (Func.isBlank(arBillSrcCode)) {
return null;
}
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
param.setAwrpSrcCode(List.of(arBillSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(AR_RESULT_PAGE_SIZE);
return bfiService.queryArBill(param);
})) {
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && arBill.getId() != null) {
return String.valueOf(arBill.getId());
}
}
return null;
}
// ==================== A302-2 财务应收单保存 ====================
/**
@@ -135,7 +162,7 @@ public class KingdeeArBillSyncSupport {
queryArBillExistNos(List.of(code)).getOrDefault(code, ""));
if (Func.isNotEmpty(existBillNo)) {
log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo);
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true);
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, null, true);
}
ContractManage contract = kingdeeApBillSyncSupport.requireContract(settlement.getContractId(), context);
// 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可
@@ -269,7 +296,10 @@ public class KingdeeArBillSyncSupport {
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单");
String billNo = saveResult.getResult().get(0).getNumber();
log.info("金蝶财务应收单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, entry.size());
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(billNo, false);
// 星瀚应收单内码随结果返回(A902 附件上传定位用)
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(savedItem.getNumber(),
savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
}
// ==================== 结算明细分录解析 ====================
@@ -0,0 +1,463 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import feign.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.transport.constant.KingdeeAttachmentConstant;
import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper;
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 金蝶附件同步支撑组件(A902 附件上传到指定单据)。
* <p>设计不变式:
* 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用);
* 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
* 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
* 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。</p>
* <p>附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
* 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
* path 变化(红冲重开)视为新文件自动触发重传)。</p>
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KingdeeAttachmentSyncSupport {
/** 用户附件下载连接/请求超时 */
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60);
private final IBfiService bfiService;
private final KingdeeAttachmentSyncMapper attachmentSyncMapper;
/** 附件下载客户端(无状态,进程内复用) */
private final HttpClient downloadClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
/**
* 待同步附件项
*
* @param name 附件名称(含扩展名,展示/金蝶侧文件名)
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path
* @param size 附件大小(字节,可空:空时下载后按实际字节校验)
* @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url
*/
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
}
/**
* 单据附件同步结果(各模块用于追加操作流水与前端提示)
*
* @param total 参与同步的附件数(不含已成功跳过)
* @param success 本次上传成功数
* @param failed 失败数
* @param skipCount 已成功幂等跳过数
* @param failReasons 失败原因明细(附件名:原因)
*/
public record AttachmentSyncSummary(int total, int success, int failed, int skipCount, List<String> failReasons) {
}
// ==================== 附件同步主流程 ====================
/**
* 同步单据附件至金蝶对应单据附件面板(A902,逐附件独立 try/catch:成功落成功记录、失败落失败记录)。
* billPkId 为空(星瀚单据内码未落库)时整单跳过并记日志,由后续重推补传。
*
* @param bizType 业务单据类型(KingdeeAttachmentConstant.BIZ_*)
* @param bizId 业务单据id
* @param billPkId 星瀚单据内码
* @param items 待同步附件列表
* @return 同步结果汇总
*/
public AttachmentSyncSummary syncAttachments(String bizType, Long bizId, String billPkId, List<KingdeeAttachmentItem> items) {
if (Func.isEmpty(items)) {
return new AttachmentSyncSummary(0, 0, 0, 0, List.of());
}
if (Func.isBlank(billPkId)) {
log.warn("金蝶附件同步跳过:业务单据[{}:{}]星瀚单据内码为空,待重推补传", bizType, bizId);
return new AttachmentSyncSummary(items.size(), 0, 0, 0, List.of());
}
// 载入已成功集合:命中即幂等跳过(重推主单时 A902 零调用)
Set<String> successKeys = successKeys(bizType, bizId);
// 载入既有同步记录(含失败记录,重试成功时覆盖为成功)
Map<String, KingdeeAttachmentSync> existingByKey = existingByKey(bizType, bizId);
String entityNumber = KingdeeAttachmentConstant.entityNumber(bizType);
log.info("金蝶附件同步开始:业务单据[{}:{}],星瀚内码={},entityNumber={},待同步附件 {} 个,历史已成功 {} 个",
bizType, bizId, billPkId, entityNumber, items.size(), successKeys.size());
int success = 0;
int skipCount = 0;
List<String> failReasons = new ArrayList<>();
List<KingdeeAttachmentSync> pendingRecords = new ArrayList<>();
for (KingdeeAttachmentItem item : items) {
if (Func.isBlank(item.key())) {
continue;
}
if (successKeys.contains(item.key())) {
skipCount++;
continue;
}
try {
// 声明大小超限直接拦截,不发起下载
if (item.size() != null && item.size() > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) {
throw new IllegalArgumentException("附件超过20MB大小限制("
+ formatSize(item.size()) + "),请压缩后重试");
}
byte[] content = fetchContent(item);
if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) {
throw new IllegalArgumentException("附件超过20MB大小限制("
+ formatSize((long) content.length) + "),请压缩后重试");
}
String fileName = Func.isBlank(item.name()) ? "attachment" : item.name();
BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId,
KingdeeAttachmentConstant.CONTROL_KEY, fileName, content);
log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath());
pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS,
null, uploadVO.getPath(), existingByKey.get(item.key())));
success++;
} catch (Exception exception) {
String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
log.warn("金蝶附件同步失败:业务单据[{}:{}],附件[{}]", bizType, bizId, item.name(), exception);
failReasons.add(Func.toStr(item.name()) + ":" + reason);
pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_FAILED,
truncateReason(reason), null, existingByKey.get(item.key())));
}
}
for (KingdeeAttachmentSync record : pendingRecords) {
if (record.getId() == null) {
attachmentSyncMapper.insert(record);
} else {
// updateById 默认忽略 null 字段,失败转成功时 fail_reason 须显式置空,避免残留历史失败原因
attachmentSyncMapper.update(record, Wrappers.<KingdeeAttachmentSync>lambdaUpdate()
.eq(KingdeeAttachmentSync::getId, record.getId())
.set(KingdeeAttachmentSync::getFailReason, record.getFailReason()));
}
}
int total = success + failReasons.size();
log.info("金蝶附件同步完成:业务单据[{}:{}],本次待传 {},成功 {},失败 {},历史已传跳过 {}",
bizType, bizId, total, success, failReasons.size(), skipCount);
return new AttachmentSyncSummary(total, success, failReasons.size(), skipCount, failReasons);
}
/**
* 同步用户附件(单据 attachmentsJson):解析后逐个上传
*
* @param bizType 业务单据类型
* @param bizId 业务单据id
* @param billPkId 星瀚单据内码
* @param attachmentsJson 单据附件JSON(数组,容忍单对象)
* @return 同步结果汇总
*/
public AttachmentSyncSummary syncUserAttachments(String bizType, Long bizId, String billPkId, String attachmentsJson) {
return syncAttachments(bizType, bizId, billPkId, parseUserAttachments(attachmentsJson));
}
// ==================== 附件解析 ====================
/**
* 解析单据用户附件(attachmentsJson):取 url/name/size,无 url 的项忽略。
* 前端附件行字段容忍取值:名称 originalName/name/fileName,地址 url/link/fileUrl/downloadUrl/domain/src
*
* @param attachmentsJson 附件JSON
* @return 附件项列表
*/
public List<KingdeeAttachmentItem> parseUserAttachments(String attachmentsJson) {
Object parsed = parseJson(attachmentsJson);
if (parsed == null) {
return List.of();
}
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
List<KingdeeAttachmentItem> items = new ArrayList<>();
for (Object row : rows) {
if (!(row instanceof Map<?, ?> map)) {
continue;
}
String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src");
if (Func.isBlank(url)) {
continue;
}
String name = firstText(map, "originalName", "name", "fileName");
Long size = firstLong(map, "size");
items.add(new KingdeeAttachmentItem(name, url, size, false));
}
return items;
}
/**
* 解析票池镜像发票文件(attachmentsJson 单对象 {source,path,name}):幂等键=path(经 A801-2 下载),
* path 变化(红冲重开)自动视为新文件触发重传
*
* @param attachmentsJson 镜像附件JSON
* @return 附件项列表(无发票文件时为空)
*/
public List<KingdeeAttachmentItem> parseInvoicePoolFile(String attachmentsJson) {
Object parsed = parseJson(attachmentsJson);
if (parsed == null) {
return List.of();
}
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
List<KingdeeAttachmentItem> items = new ArrayList<>();
for (Object row : rows) {
if (!(row instanceof Map<?, ?> map)) {
continue;
}
String path = firstText(map, "path");
if (Func.isBlank(path)) {
continue;
}
String name = firstText(map, "name");
items.add(new KingdeeAttachmentItem(Func.isBlank(name) ? "发票文件" : name, path, null, true));
}
return items;
}
// ==================== 列表聚合态 ====================
/**
* 单据附件数量(列表聚合态判定用):解析失败按 0 处理
*
* @param attachmentsJson 单据附件JSON
* @return 附件数量
*/
public int countAttachments(String attachmentsJson) {
return parseUserAttachments(attachmentsJson).size();
}
/**
* 各业务单据已成功上传附件数(一次 GROUP BY 查询,避免 N+1)
*
* @param bizType 业务单据类型
* @param bizIds 业务单据id集合
* @return 业务单据id -> 已成功附件数
*/
public Map<Long, Integer> successCountByBizId(String bizType, Collection<Long> bizIds) {
if (Func.isEmpty(bizIds)) {
return Map.of();
}
QueryWrapper<KingdeeAttachmentSync> wrapper = new QueryWrapper<KingdeeAttachmentSync>()
.select("biz_id", "count(*) AS success_count")
.eq("biz_type", bizType)
.eq("sync_status", KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS)
.in("biz_id", bizIds)
.groupBy("biz_id");
Map<Long, Integer> result = new HashMap<>();
for (Map<String, Object> row : attachmentSyncMapper.selectMaps(wrapper)) {
Object bizId = row.get("biz_id");
Object count = row.get("success_count");
if (bizId != null && count != null) {
result.put(Long.valueOf(String.valueOf(bizId)), Integer.parseInt(String.valueOf(count)));
}
}
return result;
}
/**
* 聚合态判定:无附件 / 未上传 / 部分未传 / 已上传(运行时聚合,非存储字段)
*
* @param attachmentCount 单据附件数量
* @param successCount 已成功上传数量
* @return 聚合态(KingdeeAttachmentConstant.AGGREGATE_*)
*/
public String aggregateStatus(int attachmentCount, int successCount) {
if (attachmentCount <= 0) {
return KingdeeAttachmentConstant.AGGREGATE_NONE;
}
if (successCount <= 0) {
return KingdeeAttachmentConstant.AGGREGATE_PENDING;
}
return successCount >= attachmentCount
? KingdeeAttachmentConstant.AGGREGATE_DONE : KingdeeAttachmentConstant.AGGREGATE_PARTIAL;
}
// ==================== 内部实现 ====================
/** 已成功附件幂等键集合 */
private Set<String> successKeys(String bizType, Long bizId) {
return attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>lambdaQuery()
.select(KingdeeAttachmentSync::getAttachmentKey)
.eq(KingdeeAttachmentSync::getBizType, bizType)
.eq(KingdeeAttachmentSync::getBizId, bizId)
.eq(KingdeeAttachmentSync::getSyncStatus, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS))
.stream().map(KingdeeAttachmentSync::getAttachmentKey).collect(Collectors.toSet());
}
/** 既有同步记录(含失败),key -> 记录(重试成功时覆盖为成功) */
private Map<String, KingdeeAttachmentSync> existingByKey(String bizType, Long bizId) {
Map<String, KingdeeAttachmentSync> result = new LinkedHashMap<>();
for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>lambdaQuery()
.eq(KingdeeAttachmentSync::getBizType, bizType)
.eq(KingdeeAttachmentSync::getBizId, bizId))) {
result.putIfAbsent(record.getAttachmentKey(), record);
}
return result;
}
/**
* 组装同步记录:有既有记录时复用主键覆盖更新(失败转成功/成功后清理失败原因),否则新增
*/
private KingdeeAttachmentSync buildRecord(String bizType, Long bizId, KingdeeAttachmentItem item, int syncStatus,
String failReason, String kingdeePath, KingdeeAttachmentSync existing) {
KingdeeAttachmentSync record = existing == null ? new KingdeeAttachmentSync() : existing;
record.setBizType(bizType);
record.setBizId(bizId);
record.setAttachmentKey(item.key());
record.setAttachmentName(truncate(item.name(), 250));
record.setKingdeePath(kingdeePath);
record.setSyncStatus(syncStatus);
record.setFailReason(failReason);
record.setSyncTime(LocalDateTime.now());
return record;
}
/**
* 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控);
* 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
*/
private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception {
if (item.kingdeePathSource()) {
Response response = bfiService.downloadInvoice(item.key());
try (InputStream inputStream = response.body().asInputStream()) {
return readCapped(inputStream);
} finally {
response.close();
}
}
HttpRequest request = HttpRequest.newBuilder(URI.create(item.key()))
.timeout(DOWNLOAD_TIMEOUT)
.GET()
.build();
HttpResponse<InputStream> response = downloadClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode());
}
try (InputStream inputStream = response.body()) {
return readCapped(inputStream);
}
}
/** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */
private byte[] readCapped(InputStream inputStream) throws IOException {
int bufferSize = 64 * 1024;
long limit = KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES;
ByteArrayOutputStream buffer = new ByteArrayOutputStream(bufferSize);
byte[] chunk = new byte[bufferSize];
long total = 0;
int read;
while ((read = inputStream.read(chunk)) != -1) {
if (total + read > limit) {
throw new IllegalArgumentException("附件超过20MB大小限制,请压缩后重试");
}
buffer.write(chunk, 0, read);
total += read;
}
return buffer.toByteArray();
}
private Object parseJson(String json) {
if (Func.isBlank(json)) {
return null;
}
try {
return JsonUtil.parse(json.trim(), Object.class);
} catch (Exception exception) {
log.warn("附件JSON解析失败,按无附件处理:{}", exception.getMessage());
return null;
}
}
/** 附件行字段容忍取值(多个候选键取第一个非空值) */
private String firstText(Map<?, ?> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value != null && Func.isNotBlank(String.valueOf(value))) {
return String.valueOf(value).trim();
}
}
return null;
}
private Long firstLong(Map<?, ?> map, String key) {
Object value = map.get(key);
if (value instanceof Number number) {
return number.longValue();
}
if (value != null) {
try {
return Long.parseLong(String.valueOf(value));
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String truncate(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength);
}
private String truncateReason(String reason) {
return truncate(reason, 490);
}
private String formatSize(Long bytes) {
if (bytes == null) {
return "未知";
}
return String.format("%.1fMB", bytes / 1024.0 / 1024.0);
}
}
@@ -35,6 +35,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
@@ -158,10 +159,10 @@ public class KingdeeReceivedInvoiceSyncSupport {
* @param contract 分摊应付结算单关联合同(组织/币种/往来户派生)
* @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
* @param context 金蝶同步上下文(缓存载体)
* @return 金蝶收票单单据编号
* @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用)
*/
public String pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
if (Func.isBlank(mirror.getInvoiceTypeCode())) {
throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票");
}
@@ -216,7 +217,18 @@ public class KingdeeReceivedInvoiceSyncSupport {
BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param);
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单");
return saveResult.getResult().get(0).getNumber();
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
return new ReceivedInvoicePushOutcome(savedItem.getNumber(),
savedItem.getId() == null ? null : String.valueOf(savedItem.getId()));
}
/**
* A303-2 收票单推送结果
*
* @param billNo 金蝶收票单单据编号
* @param billId 星瀚收票单内码(A902 附件上传定位用)
*/
public record ReceivedInvoicePushOutcome(String billNo, String billId) {
}
/**