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;