diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java
index c29e4c2b..0a03304b 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java
@@ -144,6 +144,10 @@ public class FormalSettlement extends TenantEntity {
* 金蝶单据编号
*/
private String kingdeeBillNo;
+ /**
+ * 金蝶单据id(星瀚内部主键,A902 附件上传定位用)
+ */
+ private String kingdeeBillId;
/**
* 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败
*/
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
index 960cb23d..2b89b282 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
@@ -159,6 +159,10 @@ public class InvoiceApplication extends TenantEntity {
* 金蝶开票申请单编号(A303-1 返回,A303-5 查询 serialNo)
*/
private String kingdeeBillNo;
+ /**
+ * 金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)
+ */
+ private String kingdeeBillId;
/**
* 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 invoiced-开票完成(终态)
*/
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
index 0843525b..9023e07e 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
@@ -152,6 +152,10 @@ public class InvoiceReceipt extends TenantEntity {
* 金蝶收票单单据编号(A303-2 批量保存生成/幂等预检命中回填)
*/
private String kingdeeReceivedInvoiceNo;
+ /**
+ * 金蝶收票单id(星瀚内部主键,A902 附件上传定位用)
+ */
+ private String kingdeeReceivedInvoiceId;
/**
* 收票单推送状态(unsynced 未推送 / pushed 已保存收票单 / assigned 已指定应付 / failed 推送失败)
*/
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java
new file mode 100644
index 00000000..c8c0377d
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java
@@ -0,0 +1,84 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+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;
+
+/**
+ * 金蝶附件同步实体类(A902 附件上传到指定单据,单附件行级状态)
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_kingdee_attachment_sync")
+@Schema(description = "金蝶附件同步")
+public class KingdeeAttachmentSync extends TenantEntity {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)
+ */
+ private String bizType;
+ /**
+ * 业务单据id
+ */
+ @JsonSerialize(using = ToStringSerializer.class)
+ private Long bizId;
+ /**
+ * 附件幂等键(用户附件=附件url;发票文件=A801-2 的 path)
+ */
+ private String attachmentKey;
+ /**
+ * 附件名称(展示/排查用)
+ */
+ private String attachmentName;
+ /**
+ * 金蝶侧文件路径(A902 返回 data.path)
+ */
+ private String kingdeePath;
+ /**
+ * 同步状态:1 成功 / 0 失败
+ */
+ private Integer syncStatus;
+ /**
+ * 失败原因(超限/下载失败/金蝶报错)
+ */
+ private String failReason;
+ /**
+ * 最近上传时间
+ */
+ private LocalDateTime syncTime;
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java
index 1e507376..942169de 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java
@@ -39,6 +39,9 @@ public class FormalSettlementVO extends FormalSettlement {
@TableField(exist = false) private String ids;
@TableField(exist = false) private String approvalStatusName;
@TableField(exist = false) private String settlementTypeName;
+ @TableField(exist = false) private Integer attachmentCount;
+ @TableField(exist = false) private String attachmentSyncStatus;
+ @TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private String preSettlementNos;
@TableField(exist = false) private String preSettlementNo;
@TableField(exist = false) private List sources;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
index 2d497480..cfc9ebcb 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
@@ -49,6 +49,9 @@ public class InvoiceApplicationVO extends InvoiceApplication {
@TableField(exist = false) private String settlementNos;
@TableField(exist = false) private String approvalStatusName;
@TableField(exist = false) private String kingdeeStatusName;
+ @TableField(exist = false) private Integer attachmentCount;
+ @TableField(exist = false) private String attachmentSyncStatus;
+ @TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private String createUserName;
@TableField(exist = false) private List settlements;
@TableField(exist = false) private List sheets;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java
index d2380266..2f344ff1 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java
@@ -49,6 +49,9 @@ public class InvoiceReceiptVO extends InvoiceReceipt {
@TableField(exist = false) private String settlementNos;
@TableField(exist = false) private String approvalStatusName;
@TableField(exist = false) private String kingdeePushStatusName;
+ @TableField(exist = false) private Integer attachmentCount;
+ @TableField(exist = false) private String attachmentSyncStatus;
+ @TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private String createUserName;
@TableField(exist = false) private String updateUserName;
@TableField(exist = false) private List settlements;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java
index 6e6c183a..0d74f7f4 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java
@@ -43,6 +43,9 @@ public class PaymentApplicationVO extends PaymentApplication {
@TableField(exist = false) private String paymentTypeName;
@TableField(exist = false) private String approvalStatusName;
@TableField(exist = false) private String kingdeeStatusName;
+ @TableField(exist = false) private Integer attachmentCount;
+ @TableField(exist = false) private String attachmentSyncStatus;
+ @TableField(exist = false) private String attachmentSyncStatusName;
@TableField(exist = false) private List invoices;
@TableField(exist = false) private List paymentRecords;
@TableField(exist = false) private List settlements;
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.java
new file mode 100644
index 00000000..1355bcc6
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.java
@@ -0,0 +1,176 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.constant;
+
+import java.util.Map;
+
+import org.springblade.core.log.exception.ServiceException;
+
+/**
+ * 金蝶附件同步(A902)常量
+ *
entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构
+ *
+ * @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 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 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() {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java
new file mode 100644
index 00000000..a34b3f36
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeAttachmentSyncMapper.java
@@ -0,0 +1,39 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * 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 {
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java
index f49babc3..6cb95845 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java
@@ -40,6 +40,16 @@ public interface IFormalSettlementService extends BaseService
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 detailFees(Long detailId);
void adjustDetail(PreSettlementDetailAdjustRequest request);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
index b126e786..92e01890 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
@@ -55,5 +55,15 @@ public interface IInvoiceApplicationService extends BaseService
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * 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 {
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java
index 38769d54..5d3a4b09 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java
@@ -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;
@@ -80,11 +81,13 @@ import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
+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.springblade.core.tool.utils.SpringUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -139,6 +142,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, FormalSettlementVO query) {
@@ -167,7 +171,35 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
- return result.convert(this::toVO);
+ IPage voPage = result.convert(this::toVO);
+ fillAttachmentSyncStatus(voPage.getRecords());
+ return voPage;
+ }
+
+ /**
+ * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1
+ *
+ * @param rows 本页 VO 列表
+ */
+ private void fillAttachmentSyncStatus(List rows) {
+ if (Func.isEmpty(rows)) {
+ return;
+ }
+ Map apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
+ KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream()
+ .filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
+ Map 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 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
@@ -475,11 +507,76 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl details = detailMapper.selectList(Wrappers.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 预检本单应付单已生成
@@ -493,6 +590,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) {
@@ -155,7 +159,30 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 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 rows) {
+ if (Func.isEmpty(rows)) {
+ return;
+ }
+ Map 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
@@ -467,11 +494,47 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 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();
@@ -521,8 +584,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl 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 rows) {
+ if (Func.isEmpty(rows)) {
+ return;
+ }
+ Map 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
@@ -695,6 +721,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 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 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(不拦截整批,逐单报因)
*/
@@ -815,11 +884,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * 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
+ implements IKingdeeAttachmentSyncService {
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
index cdb00917..210dac0f 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
@@ -47,6 +47,7 @@ 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;
@@ -92,6 +93,7 @@ import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
+import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
import org.springblade.transport.wrapper.PaymentApplicationWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -140,6 +142,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl {
+ IPage 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 rows) {
+ if (Func.isEmpty(rows)) {
+ return;
+ }
+ Map 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
@@ -358,9 +385,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl {
+ 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 财务应付单保存 ====================
/**
@@ -186,7 +212,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(contractId, context);
// 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可
@@ -290,9 +316,10 @@ public class KingdeeApBillSyncSupport {
// TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代
BfiBillSaveResultVO saveResult = bfiService.saveApBill(param);
- // 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
- return new BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false);
+ // 星瀚应付单内码随结果返回(A902 附件上传定位用)
+ BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
+ return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
}
// ==================== 结算明细分录解析 ====================
@@ -675,8 +702,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) {
}
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
index 86179f3e..65ab23fd 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
@@ -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;
@@ -112,6 +113,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 财务应收单保存 ====================
/**
@@ -137,7 +164,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(contractId, context);
// 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可
@@ -229,9 +256,11 @@ public class KingdeeArBillSyncSupport {
// 收款计划 planentity 不传:由金蝶按明细自动汇总生成(启用方案待业务确认后迭代)
BfiBillSaveResultVO saveResult = bfiService.saveArBill(param);
- // 应收单id不落库,后续如需可经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单");
- return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(saveResult.getResult().get(0).getNumber(), false);
+ // 星瀚应收单内码随结果返回(A902 附件上传定位用)
+ BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
+ return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(savedItem.getNumber(),
+ savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
}
// ==================== 结算明细分录解析 ====================
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java
new file mode 100644
index 00000000..cf8e2d69
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java
@@ -0,0 +1,457 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * 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 附件上传到指定单据)。
+ *
设计不变式:
+ * 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用);
+ * 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
+ * 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
+ * 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。
+ * 附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
+ * 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
+ * path 变化(红冲重开)视为新文件自动触发重传)。
+ *
+ * @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 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 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 successKeys = successKeys(bizType, bizId);
+ // 载入既有同步记录(含失败记录,重试成功时覆盖为成功)
+ Map existingByKey = existingByKey(bizType, bizId);
+ String entityNumber = KingdeeAttachmentConstant.entityNumber(bizType);
+ int success = 0;
+ int skipCount = 0;
+ List failReasons = new ArrayList<>();
+ List 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);
+ 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 {
+ attachmentSyncMapper.updateById(record);
+ }
+ }
+ 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 parseUserAttachments(String attachmentsJson) {
+ Object parsed = parseJson(attachmentsJson);
+ if (parsed == null) {
+ return List.of();
+ }
+ List> rows = parsed instanceof List> list ? list : List.of(parsed);
+ List 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 parseInvoicePoolFile(String attachmentsJson) {
+ Object parsed = parseJson(attachmentsJson);
+ if (parsed == null) {
+ return List.of();
+ }
+ List> rows = parsed instanceof List> list ? list : List.of(parsed);
+ List 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 successCountByBizId(String bizType, Collection bizIds) {
+ if (Func.isEmpty(bizIds)) {
+ return Map.of();
+ }
+ QueryWrapper wrapper = new QueryWrapper()
+ .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 result = new HashMap<>();
+ for (Map 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 successKeys(String bizType, Long bizId) {
+ return attachmentSyncMapper.selectList(Wrappers.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 existingByKey(String bizType, Long bizId) {
+ Map result = new LinkedHashMap<>();
+ for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.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 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);
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java
index bba9d18a..0e514b28 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java
@@ -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 entryGroups, KingdeeSyncContext context) {
+ public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
+ List 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) {
}
/**
diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java
index c2e5c1ff..2f794ca4 100644
--- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java
+++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java
@@ -48,6 +48,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
+import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
@@ -65,11 +66,14 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.SpringQueryMap;
+import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -106,6 +110,7 @@ public interface IBfiClient {
String CLAIM_BILL_PUSH_URL = "${thirdParty.bfi.claimBillPushUrl:/ierp/kapi/v2/awrp/cas/cas/cas_claimbill/claimbillPush}";
String INVOICE_POOL_QUERY_URL = "${thirdParty.bfi.invoicePoolQueryUrl:/ierp/kapi/v2/awrp/rim/rim_invoice/query}";
String INVOICE_DOWNLOAD_URL = "${thirdParty.bfi.invoiceDownloadUrl:/ierp/kapi/v2/awrp/bosext/attachment/download}";
+ String ATTACHMENT_UPLOAD_URL = "${thirdParty.bfi.attachmentUploadUrl:/ierp/kapi/v2/frame/attachment/uploadFile}";
/**
* A100-1 获取令牌
@@ -451,4 +456,17 @@ public interface IBfiClient {
*/
@PostMapping(INVOICE_DOWNLOAD_URL)
Response invoiceDownload(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceDownloadDTO param);
+
+ /**
+ * A902 附件上传到指定单据(multipart/form-data)
+ *
+ * @param accessToken 访问令牌,通过 getToken 获取
+ * @param file 附件文件
+ * @param attachmentUploadFileArgs 上传参数 JSON 字符串:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"}
+ * @return 上传结果,data.path 即金蝶侧文件路径
+ */
+ @PostMapping(value = ATTACHMENT_UPLOAD_URL, produces = MediaType.MULTIPART_FORM_DATA_VALUE)
+ BfiResultVO uploadAttachment(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken,
+ @RequestPart("file") MultipartFile file,
+ @RequestPart("attachmentUploadFileArgs") String attachmentUploadFileArgs);
}
diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java
new file mode 100644
index 00000000..0e3437ea
--- /dev/null
+++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java
@@ -0,0 +1,57 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.thirdparty.bfi.pojo.vo;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 业财融合(BFI)A902 附件上传返回数据
+ *
+ * 响应示例:
+ *
{@code
+ * {
+ * "data": {"path": "211e6ce5c9805c00"},
+ * "errorCode": "0",
+ * "status": true
+ * }
+ * }
+ *
+ * @author Chill
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class BfiAttachmentUploadVO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 金蝶侧文件路径(附件面板回显/排查用)
+ */
+ private String path;
+}
diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java
index 0faceef1..c86df5ec 100644
--- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java
+++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java
@@ -49,6 +49,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
+import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
@@ -293,4 +294,16 @@ public interface IBfiService {
* @return 文件流响应,调用方负责关闭
*/
Response downloadInvoice(String path);
+
+ /**
+ * A902 附件上传到指定单据
+ *
+ * @param entityNumber 星瀚单据标识(如 ap_payapply)
+ * @param billPkId 星瀚单据内码(保存/查询响应返回的 id)
+ * @param controlKey 附件面板标识,统一 attachmentpanel
+ * @param fileName 附件名称(含扩展名)
+ * @param fileContent 附件内容
+ * @return 上传结果,path 即金蝶侧文件路径
+ */
+ BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent);
}
diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java
index a2d84e33..f5c831d4 100644
--- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java
+++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java
@@ -73,6 +73,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
+import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
@@ -91,6 +92,8 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
+import org.springblade.thirdparty.bfi.util.InMemoryMultipartFile;
+import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
@@ -548,6 +551,30 @@ public class BfiServiceImpl implements IBfiService {
}
}
+ @Override
+ public BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent) {
+ if (StringUtil.isBlank(entityNumber) || StringUtil.isBlank(billPkId)) {
+ throw new BfiException("上传BFI附件失败,entityNumber与billPkId不能为空");
+ }
+ if (StringUtil.isBlank(fileName) || fileContent == null || fileContent.length == 0) {
+ throw new BfiException("上传BFI附件失败,附件名称与内容不能为空");
+ }
+ String accessToken = getAccessToken();
+ // A902 参数原文 JSON:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"}
+ JSONObject args = new JSONObject();
+ args.set("entityNumber", entityNumber);
+ args.set("billPkId", billPkId);
+ args.set("controlKey", StringUtil.isBlank(controlKey) ? "attachmentpanel" : controlKey);
+ InMemoryMultipartFile file = new InMemoryMultipartFile("file", fileName, MediaType.APPLICATION_OCTET_STREAM_VALUE, fileContent);
+ BfiResultVO result = commonCall("附件上传", () -> client.uploadAttachment(accessToken, file, args.toString()));
+ BfiAttachmentUploadVO uploadVO = result.getData();
+ if (uploadVO == null || StringUtil.isBlank(uploadVO.getPath())) {
+ log.error("调用BFI接口 附件上传返回数据为空:{}", JSONUtil.toJsonStr(result));
+ throw new BfiException("调用BFI接口附件上传异常,返回数据为空");
+ }
+ return uploadVO;
+ }
+
/**
* 公共调用方法,统一校验响应结果
*
diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java
new file mode 100644
index 00000000..37b09524
--- /dev/null
+++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java
@@ -0,0 +1,101 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.thirdparty.bfi.util;
+
+import org.springframework.lang.NonNull;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * 内存字节数组版 MultipartFile:A902 附件上传经 Feign multipart 发送时使用,
+ * 文件内容已整体读入内存(单文件软限制 20MB,见 KingdeeAttachmentConstant)
+ *
+ * @author Chill
+ */
+public class InMemoryMultipartFile implements MultipartFile {
+
+ private final String name;
+ private final String originalFilename;
+ private final String contentType;
+ private final byte[] content;
+
+ public InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] content) {
+ this.name = name;
+ this.originalFilename = originalFilename;
+ this.contentType = contentType;
+ this.content = content == null ? new byte[0] : content;
+ }
+
+ @Override
+ @NonNull
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getOriginalFilename() {
+ return originalFilename;
+ }
+
+ @Override
+ public String getContentType() {
+ return contentType;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return content.length == 0;
+ }
+
+ @Override
+ public long getSize() {
+ return content.length;
+ }
+
+ @Override
+ @NonNull
+ public byte[] getBytes() {
+ return content;
+ }
+
+ @Override
+ @NonNull
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(content);
+ }
+
+ @Override
+ public void transferTo(@NonNull File destination) throws IOException {
+ try (FileOutputStream outputStream = new FileOutputStream(destination)) {
+ outputStream.write(content);
+ }
+ }
+}
diff --git a/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql
new file mode 100644
index 00000000..06e56137
--- /dev/null
+++ b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql
@@ -0,0 +1,83 @@
+-- 金蝶附件同步(A902 附件上传到指定单据)
+-- 新增附件同步表 blade_kingdee_attachment_sync:单附件行级同步状态(幂等键 attachment_key,成功终态跳过);
+-- 单据表补列星瀚单据内码(A902 定位目标单据的 billPkId):
+-- blade_formal_settlement.kingdee_bill_id 正式结算(应付 A102-2 / 应收 A302-2 保存响应返回)
+-- blade_invoice_receipt.kingdee_received_invoice_id 收票单内码(A303-2 保存响应 / A801-3 预检回填)
+-- blade_invoice_application.kingdee_bill_id 开票申请单内码(A303-1 保存响应 / A303-4 预检回填)
+-- 以下结构升级支持重复执行
+
+-- ① blade_kingdee_attachment_sync 建表
+SET @ddl_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'CREATE TABLE `blade_kingdee_attachment_sync` (
+ `id` bigint NOT NULL COMMENT ''主键'',
+ `tenant_id` varchar(12) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''租户ID'',
+ `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)'',
+ `biz_id` bigint NOT NULL COMMENT ''业务单据id'',
+ `attachment_key` varchar(768) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''附件幂等键(用户附件=附件url;发票文件=A801-2的path)'',
+ `attachment_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''附件名称(展示/排查用)'',
+ `kingdee_path` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶侧文件路径(A902 返回 data.path)'',
+ `sync_status` tinyint NOT NULL DEFAULT 0 COMMENT ''同步状态:1 成功 / 0 失败'',
+ `fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''失败原因(超限/下载失败/金蝶报错)'',
+ `sync_time` datetime 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`),
+ UNIQUE KEY `uk_biz_attachment` (`biz_type`,`biz_id`,`attachment_key`),
+ KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=''金蝶附件同步表(A902)''',
+ 'SELECT 1')
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'blade_kingdee_attachment_sync'
+);
+PREPARE ddl_stmt FROM @ddl_sql;
+EXECUTE ddl_stmt;
+DEALLOCATE PREPARE ddl_stmt;
+
+-- ② blade_formal_settlement.kingdee_bill_id
+SET @ddl_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'blade_formal_settlement'
+ AND COLUMN_NAME = 'kingdee_bill_id'
+);
+PREPARE ddl_stmt FROM @ddl_sql;
+EXECUTE ddl_stmt;
+DEALLOCATE PREPARE ddl_stmt;
+
+-- ③ blade_invoice_receipt.kingdee_received_invoice_id
+SET @ddl_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_received_invoice_no`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'blade_invoice_receipt'
+ AND COLUMN_NAME = 'kingdee_received_invoice_id'
+);
+PREPARE ddl_stmt FROM @ddl_sql;
+EXECUTE ddl_stmt;
+DEALLOCATE PREPARE ddl_stmt;
+
+-- ④ blade_invoice_application.kingdee_bill_id
+SET @ddl_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'blade_invoice_application'
+ AND COLUMN_NAME = 'kingdee_bill_id'
+);
+PREPARE ddl_stmt FROM @ddl_sql;
+EXECUTE ddl_stmt;
+DEALLOCATE PREPARE ddl_stmt;