Merge remote-tracking branch 'origin/feature/kingdee-attachment-sync' into feature/payment-apply-bfi-sync
# Conflicts: # blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java # blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java # blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
This commit is contained in:
@@ -34,9 +34,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 自定义Feign日志记录器,只记录出入参信息
|
||||
@@ -50,6 +54,7 @@ public class CustomFeignLogger extends Logger {
|
||||
private static final String IMAGE_CONTENT_TYPE_PREFIX = "image/";
|
||||
private static final String AUDIO_CONTENT_TYPE_PREFIX = "audio/";
|
||||
private static final String VIDEO_CONTENT_TYPE_PREFIX = "video/";
|
||||
private static final String MULTIPART_CONTENT_TYPE_PREFIX = "multipart/";
|
||||
|
||||
@Override
|
||||
protected void log(String configKey, String format, Object... args) {
|
||||
@@ -63,10 +68,13 @@ public class CustomFeignLogger extends Logger {
|
||||
Map<String, Collection<String>> headers = request.headers();
|
||||
byte[] body = request.body();
|
||||
|
||||
String contentType = getContentType(headers);
|
||||
String bodyStr;
|
||||
if (body == null || body.length == 0) {
|
||||
bodyStr = "[无]";
|
||||
} else if (isBinaryContent(getContentType(headers))) {
|
||||
} else if (isMultipartContent(contentType)) {
|
||||
bodyStr = parseMultipartBody(body, contentType);
|
||||
} else if (isBinaryContent(contentType)) {
|
||||
bodyStr = "[二进制数据,长度=" + body.length + "字节]";
|
||||
} else {
|
||||
bodyStr = new String(body, StandardCharsets.UTF_8);
|
||||
@@ -160,4 +168,75 @@ public class CustomFeignLogger extends Logger {
|
||||
|| lowerContentType.startsWith(AUDIO_CONTENT_TYPE_PREFIX)
|
||||
|| lowerContentType.startsWith(VIDEO_CONTENT_TYPE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为multipart内容
|
||||
*/
|
||||
private boolean isMultipartContent(String contentType) {
|
||||
return contentType != null && contentType.toLowerCase().startsWith(MULTIPART_CONTENT_TYPE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析multipart请求体,文件部分用占位符替代,文本字段保留打印
|
||||
*/
|
||||
private String parseMultipartBody(byte[] body, String contentType) {
|
||||
String boundary = extractBoundary(contentType);
|
||||
if (boundary == null) {
|
||||
return "[multipart数据,长度=" + body.length + "字节]";
|
||||
}
|
||||
String bodyStr = new String(body, StandardCharsets.UTF_8);
|
||||
String delimiter = "--" + boundary;
|
||||
String[] parts = bodyStr.split(delimiter);
|
||||
|
||||
List<String> fields = new ArrayList<>();
|
||||
for (String part : parts) {
|
||||
if (part.isEmpty() || part.startsWith("--") || part.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int headerEnd = part.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
continue;
|
||||
}
|
||||
String headers = part.substring(0, headerEnd);
|
||||
String value = part.substring(headerEnd + 4);
|
||||
if (value.endsWith("\r\n")) {
|
||||
value = value.substring(0, value.length() - 2);
|
||||
}
|
||||
|
||||
String name = extractParam(headers, "name");
|
||||
|
||||
if (headers.contains("filename=")) {
|
||||
String filename = extractParam(headers, "filename");
|
||||
fields.add(name + "=[文件: " + filename + "]");
|
||||
} else {
|
||||
fields.add(name + "=" + value);
|
||||
}
|
||||
}
|
||||
return fields.isEmpty() ? "[multipart数据]" : "{" + String.join(", ", fields) + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Content-Type中提取boundary
|
||||
*/
|
||||
private String extractBoundary(String contentType) {
|
||||
for (String param : contentType.split(";")) {
|
||||
param = param.trim();
|
||||
if (param.toLowerCase().startsWith("boundary=")) {
|
||||
return param.substring("boundary=".length()).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从headers中提取参数值(如name、filename)
|
||||
*/
|
||||
private String extractParam(String headers, String paramName) {
|
||||
Pattern pattern = Pattern.compile(paramName + "=\"([^\"]*)\"");
|
||||
Matcher matcher = pattern.matcher(headers);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return paramName;
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -144,6 +144,10 @@ public class FormalSettlement extends TenantEntity {
|
||||
* 金蝶单据编号
|
||||
*/
|
||||
private String kingdeeBillNo;
|
||||
/**
|
||||
* 金蝶单据id(星瀚内部主键,A902 附件上传定位用)
|
||||
*/
|
||||
private String kingdeeBillId;
|
||||
/**
|
||||
* 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败
|
||||
*/
|
||||
|
||||
+4
@@ -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-开票完成(终态)
|
||||
*/
|
||||
|
||||
+4
@@ -152,6 +152,10 @@ public class InvoiceReceipt extends TenantEntity {
|
||||
* 金蝶收票单单据编号(A303-2 批量保存生成/幂等预检命中回填)
|
||||
*/
|
||||
private String kingdeeReceivedInvoiceNo;
|
||||
/**
|
||||
* 金蝶收票单id(星瀚内部主键,A902 附件上传定位用)
|
||||
*/
|
||||
private String kingdeeReceivedInvoiceId;
|
||||
/**
|
||||
* 收票单推送状态(unsynced 未推送 / pushed 已保存收票单 / assigned 已指定应付 / failed 推送失败)
|
||||
*/
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.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;
|
||||
}
|
||||
+3
@@ -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<FormalSettlementSource> sources;
|
||||
|
||||
+3
@@ -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<InvoiceApplicationSettlement> settlements;
|
||||
@TableField(exist = false) private List<InvoiceApplicationSheetVO> sheets;
|
||||
|
||||
+3
@@ -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<InvoiceReceiptSettlement> settlements;
|
||||
|
||||
+3
@@ -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<PaymentApplicationInvoice> invoices;
|
||||
@TableField(exist = false) private List<PaymentApplicationRecord> paymentRecords;
|
||||
@TableField(exist = false) private List<PaymentApplicationSettlement> settlements;
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.constant;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* 金蝶附件同步(A902)常量
|
||||
* <p>entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构</p>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class KingdeeAttachmentConstant {
|
||||
|
||||
// ==================== 业务单据类型(biz_type) ====================
|
||||
|
||||
/**
|
||||
* 付款申请单
|
||||
*/
|
||||
public static final String BIZ_PAY_APPLICATION = "pay_application";
|
||||
/**
|
||||
* 正式结算-财务应付单面板
|
||||
*/
|
||||
public static final String BIZ_FORMAL_SETTLEMENT_AP = "formal_settlement_ap";
|
||||
/**
|
||||
* 正式结算-财务应收单面板
|
||||
*/
|
||||
public static final String BIZ_FORMAL_SETTLEMENT_AR = "formal_settlement_ar";
|
||||
/**
|
||||
* 收票单
|
||||
*/
|
||||
public static final String BIZ_INVOICE_RECEIPT = "invoice_receipt";
|
||||
/**
|
||||
* 开票申请单
|
||||
*/
|
||||
public static final String BIZ_INVOICE_APPLICATION = "invoice_application";
|
||||
|
||||
// ==================== 星瀚 entityNumber(A902 定位单据类型) ====================
|
||||
|
||||
/**
|
||||
* 付款申请单
|
||||
*/
|
||||
public static final String ENTITY_PAY_APPLICATION = "ap_payapply";
|
||||
/**
|
||||
* 财务应付单(正式结算应付面板)
|
||||
*/
|
||||
public static final String ENTITY_AP_BILL = "ap_finapbill";
|
||||
/**
|
||||
* 财务应收单(正式结算应收面板)
|
||||
*/
|
||||
public static final String ENTITY_AR_BILL = "ar_finarbill";
|
||||
/**
|
||||
* 收票单
|
||||
*/
|
||||
public static final String ENTITY_INVOICE = "ap_invoice";
|
||||
/**
|
||||
* 开票申请单
|
||||
*/
|
||||
public static final String ENTITY_INVOICE_APPLICATION = "sim_original_bill";
|
||||
|
||||
/**
|
||||
* 业务单据类型 -> 星瀚 entityNumber 映射;
|
||||
* 客商(资质附件)二期接入:待金蝶确认后补充专用 biz_type 与 entityNumber 条目
|
||||
*/
|
||||
public static final Map<String, String> ENTITY_NUMBERS = Map.of(
|
||||
BIZ_PAY_APPLICATION, ENTITY_PAY_APPLICATION,
|
||||
BIZ_FORMAL_SETTLEMENT_AP, ENTITY_AP_BILL,
|
||||
BIZ_FORMAL_SETTLEMENT_AR, ENTITY_AR_BILL,
|
||||
BIZ_INVOICE_RECEIPT, ENTITY_INVOICE,
|
||||
BIZ_INVOICE_APPLICATION, ENTITY_INVOICE_APPLICATION
|
||||
);
|
||||
|
||||
/**
|
||||
* 星瀚附件面板/附件字段标识(各单据统一)
|
||||
*/
|
||||
public static final String CONTROL_KEY = "attachmentpanel";
|
||||
|
||||
// ==================== 限制 ====================
|
||||
|
||||
/**
|
||||
* 单文件软限制 20MB(待金蝶确认后调整);超限记失败原因跳过,不阻塞同单其余附件
|
||||
*/
|
||||
public static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024;
|
||||
|
||||
// ==================== 同步行状态(sync_status) ====================
|
||||
|
||||
/**
|
||||
* 同步失败
|
||||
*/
|
||||
public static final int SYNC_STATUS_FAILED = 0;
|
||||
/**
|
||||
* 同步成功(终态,幂等跳过)
|
||||
*/
|
||||
public static final int SYNC_STATUS_SUCCESS = 1;
|
||||
|
||||
// ==================== 附件同步聚合态(列表展示,运行时聚合非存储字段) ====================
|
||||
|
||||
/**
|
||||
* 无附件:单据 attachmentsJson 无可上传附件
|
||||
*/
|
||||
public static final String AGGREGATE_NONE = "none";
|
||||
/**
|
||||
* 未上传:有附件且成功数为 0
|
||||
*/
|
||||
public static final String AGGREGATE_PENDING = "pending";
|
||||
/**
|
||||
* 部分未传:有附件且部分上传成功
|
||||
*/
|
||||
public static final String AGGREGATE_PARTIAL = "partial";
|
||||
/**
|
||||
* 已上传:有附件且全部上传成功
|
||||
*/
|
||||
public static final String AGGREGATE_DONE = "done";
|
||||
|
||||
/**
|
||||
* 聚合态 -> 展示名称
|
||||
*/
|
||||
public static final Map<String, String> AGGREGATE_NAMES = Map.of(
|
||||
AGGREGATE_NONE, "无附件",
|
||||
AGGREGATE_PENDING, "未上传",
|
||||
AGGREGATE_PARTIAL, "部分未传",
|
||||
AGGREGATE_DONE, "已上传"
|
||||
);
|
||||
|
||||
/**
|
||||
* 业务单据类型对应星瀚 entityNumber
|
||||
*
|
||||
* @param bizType 业务单据类型
|
||||
* @return entityNumber,未配置时抛业务异常(entityNumber 是硬约束,猜错即传不上)
|
||||
*/
|
||||
public static String entityNumber(String bizType) {
|
||||
String entityNumber = ENTITY_NUMBERS.get(bizType);
|
||||
if (entityNumber == null) {
|
||||
throw new ServiceException("金蝶附件上传失败:业务单据类型[" + bizType + "]未配置 entityNumber 映射");
|
||||
}
|
||||
return entityNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合态展示名称(未知态兜底返回原文)
|
||||
*
|
||||
* @param aggregateStatus 聚合态
|
||||
* @return 展示名称
|
||||
*/
|
||||
public static String aggregateName(String aggregateStatus) {
|
||||
return AGGREGATE_NAMES.getOrDefault(aggregateStatus, aggregateStatus);
|
||||
}
|
||||
|
||||
private KingdeeAttachmentConstant() {
|
||||
}
|
||||
}
|
||||
+9
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
@@ -86,6 +87,14 @@ public class InvoiceReceiptController extends BladeController {
|
||||
return R.data(invoiceReceiptService.invoicePoolPage(query));
|
||||
}
|
||||
|
||||
@GetMapping("/invoice-pool/attachment/download")
|
||||
@ApiOperationSupport(order = 17)
|
||||
@Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)",
|
||||
description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误")
|
||||
public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) {
|
||||
invoiceReceiptService.downloadPoolAttachment(invoiceNo, response);
|
||||
}
|
||||
|
||||
@GetMapping("/settlement-candidates")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "可关联的应付正式结算单")
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
|
||||
|
||||
/**
|
||||
* 金蝶附件同步 Mapper
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Mapper
|
||||
public interface KingdeeAttachmentSyncMapper extends BaseMapper<KingdeeAttachmentSync> {
|
||||
}
|
||||
+10
@@ -40,6 +40,16 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
|
||||
void returnBill(FormalSettlementStatusRequest request);
|
||||
void voidBill(FormalSettlementStatusRequest request);
|
||||
String syncKingdee(Long id);
|
||||
|
||||
/**
|
||||
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
|
||||
* A102-2/A302-2 保存并回写结算单单据编号与星瀚内码(kingdeeBillId)
|
||||
*
|
||||
* @param id 正式结算单id
|
||||
* @return 金蝶单据编号
|
||||
*/
|
||||
String doSyncKingdee(Long id);
|
||||
|
||||
String syncKingdeeArResult();
|
||||
List<FormalSettlementDetailFee> detailFees(Long detailId);
|
||||
void adjustDetail(PreSettlementDetailAdjustRequest request);
|
||||
|
||||
+10
@@ -55,5 +55,15 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
|
||||
void returnBill(InvoiceApplicationStatusRequest request);
|
||||
void voidBill(InvoiceApplicationStatusRequest request);
|
||||
String syncKingdee(Long id);
|
||||
|
||||
/**
|
||||
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
|
||||
* A303-1 保存并回写开票申请单单据编号与星瀚内码(kingdeeBillId)
|
||||
*
|
||||
* @param id 开票申请id
|
||||
* @return 金蝶开票申请单编号
|
||||
*/
|
||||
String doSyncKingdee(Long id);
|
||||
|
||||
String syncKingdeeInvoiceResult();
|
||||
}
|
||||
|
||||
+12
@@ -26,6 +26,7 @@
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
|
||||
@@ -58,6 +59,17 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
|
||||
*/
|
||||
KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
|
||||
|
||||
/**
|
||||
* 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。
|
||||
* <p>按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
|
||||
* 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器,
|
||||
* 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。
|
||||
*
|
||||
* @param invoiceNo 发票号码
|
||||
* @param response 响应对象(文件流写出口)
|
||||
*/
|
||||
void downloadPoolAttachment(String invoiceNo, HttpServletResponse response);
|
||||
|
||||
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
|
||||
|
||||
Map<String, Object> referenceInformation(String settlementIds);
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
|
||||
|
||||
/**
|
||||
* 金蝶附件同步服务
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IKingdeeAttachmentSyncService extends BaseService<KingdeeAttachmentSync> {
|
||||
}
|
||||
+109
-4
@@ -36,6 +36,7 @@ import org.springblade.transport.mapper.PaymentApplicationMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationSettlementMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
|
||||
@@ -81,12 +82,14 @@ import org.springblade.transport.service.KingdeeSyncContext;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeArBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
|
||||
import org.springblade.transport.wrapper.PreSettlementWrapper;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.transport.wrapper.FormalSettlementWrapper;
|
||||
import org.springblade.transport.wrapper.PaymentApplicationWrapper;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springblade.core.tool.utils.SpringUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -144,6 +147,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
private final IWaybillService waybillService;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
|
||||
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
|
||||
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
|
||||
|
||||
/**
|
||||
@@ -180,7 +184,35 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
if (ids.isEmpty()) wrapper.eq(FormalSettlement::getId, -1L); else wrapper.in(FormalSettlement::getId, ids);
|
||||
}
|
||||
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
|
||||
return result.convert(this::toVO);
|
||||
IPage<FormalSettlementVO> voPage = result.convert(this::toVO);
|
||||
fillAttachmentSyncStatus(voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1
|
||||
*
|
||||
* @param rows 本页 VO 列表
|
||||
*/
|
||||
private void fillAttachmentSyncStatus(List<FormalSettlementVO> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, Integer> apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
|
||||
KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream()
|
||||
.filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
|
||||
Map<Long, Integer> arSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
|
||||
KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR, rows.stream()
|
||||
.filter(row -> "receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
|
||||
for (FormalSettlementVO vo : rows) {
|
||||
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
|
||||
Map<Long, Integer> successCounts = "receivable".equals(vo.getSettlementType()) ? arSuccessCounts : apSuccessCounts;
|
||||
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
|
||||
successCounts.getOrDefault(vo.getId(), 0));
|
||||
vo.setAttachmentCount(attachmentCount);
|
||||
vo.setAttachmentSyncStatus(aggregateStatus);
|
||||
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -488,11 +520,77 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String syncKingdee(Long id) {
|
||||
// 单据级事务经自代理调用:单条失败只回滚自身
|
||||
String billNo = SpringUtil.getBean(IFormalSettlementService.class).doSyncKingdee(id);
|
||||
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
|
||||
syncAttachmentsAfterPush(id);
|
||||
return billNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,以本地 kingdeeBillId 定位金蝶单据;
|
||||
* 本地缺失(存量数据/幂等跳过路径未带回内码)时经 A102-4/A302-4 按防重键反查回填;
|
||||
* 附件上传异常独立捕获,绝不影响主单同步结果
|
||||
*
|
||||
* @param id 正式结算单id
|
||||
*/
|
||||
private void syncAttachmentsAfterPush(Long id) {
|
||||
try {
|
||||
FormalSettlement settlement = getById(id);
|
||||
if (settlement == null || !"synced".equals(settlement.getKingdeeSyncStatus())) {
|
||||
return;
|
||||
}
|
||||
String billPkId = settlement.getKingdeeBillId();
|
||||
if (Func.isBlank(billPkId)) {
|
||||
billPkId = resolveKingdeeBillId(settlement);
|
||||
if (Func.isNotBlank(billPkId)) {
|
||||
log.info("正式结算单[{}]经 A102-4/A302-4 反查回填星瀚单据内码:{}", settlement.getFormalSettlementNo(), billPkId);
|
||||
FormalSettlement patch = new FormalSettlement();
|
||||
patch.setId(settlement.getId());
|
||||
patch.setKingdeeBillId(billPkId);
|
||||
updateById(patch);
|
||||
}
|
||||
}
|
||||
if (Func.isBlank(billPkId)) {
|
||||
log.warn("正式结算单[{}]金蝶附件联带上传跳过:星瀚单据内码未落库且反查未命中,待重推补传",
|
||||
settlement.getFormalSettlementNo());
|
||||
return;
|
||||
}
|
||||
String bizType = "receivable".equals(settlement.getSettlementType())
|
||||
? KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR : KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP;
|
||||
kingdeeAttachmentSyncSupport.syncUserAttachments(bizType, id, billPkId, settlement.getAttachmentsJson());
|
||||
} catch (Exception exception) {
|
||||
log.error("正式结算单[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反查星瀚单据内码:按结算类型组装防重键(与 A102-2/A302-2 传参一致)后调用对应查询接口
|
||||
*
|
||||
* @param settlement 正式结算单
|
||||
* @return 星瀚单据内码,未查得返回 null
|
||||
*/
|
||||
private String resolveKingdeeBillId(FormalSettlement settlement) {
|
||||
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
|
||||
.eq(FormalSettlementDetail::getIsDeleted, 0)
|
||||
.orderByAsc(FormalSettlementDetail::getLineNo));
|
||||
if (Func.isEmpty(details)) {
|
||||
return null;
|
||||
}
|
||||
if ("receivable".equals(settlement.getSettlementType())) {
|
||||
return kingdeeArBillSyncSupport.queryArBillExistId(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
|
||||
}
|
||||
return kingdeeApBillSyncSupport.queryApBillExistId(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String doSyncKingdee(Long id) {
|
||||
FormalSettlement settlement = existing(id);
|
||||
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
|
||||
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据)
|
||||
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填
|
||||
if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo();
|
||||
// 流程拆分:正式结算单「同步金蝶」负责推送财务应付单(A102-2)并回写结算单单据编号;
|
||||
// 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推),推送前经 A102-4 预检本单应付单已生成
|
||||
@@ -506,6 +604,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
KingdeeSyncContext context = KingdeeSyncContext.empty();
|
||||
// 单据类型路由:应付对应财务应付单(A102-2),应收对应财务应收单(A302-2),两链路同一套幂等预检与推送逻辑
|
||||
String billNo;
|
||||
String billPkId;
|
||||
String changeDesc;
|
||||
if ("receivable".equals(settlement.getSettlementType())) {
|
||||
// 应收结算:财务应收单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接
|
||||
@@ -514,6 +613,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeArBillSyncSupport.pushArBill(
|
||||
arBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context);
|
||||
billNo = outcome.billNo();
|
||||
billPkId = outcome.billId();
|
||||
changeDesc = "同步金蝶财务应收单" + billNo;
|
||||
} else {
|
||||
// 应付结算:财务应付单防重键,付款申请「同步金蝶」(A103-1 应付下推)推送前按该键经 A102-4 预检本单应付单已生成
|
||||
@@ -523,14 +623,19 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill(
|
||||
apBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context);
|
||||
billNo = outcome.billNo();
|
||||
billPkId = outcome.billId();
|
||||
changeDesc = "同步金蝶财务应付单" + billNo;
|
||||
}
|
||||
// 回写金蝶单据编号(A102-2/A302-2 单据编号)与同步状态;付款申请侧回填的付款申请单编号(A103-2)不是同一单据
|
||||
// 回写金蝶单据编号(A102-2/A302-2 单据编号)、星瀚单据内码(A902 附件上传定位用)与同步状态;
|
||||
// 付款申请侧回填的付款申请单编号(A103-2)不是同一单据
|
||||
settlement.setKingdeeBillNo(billNo);
|
||||
settlement.setKingdeeBillId(billPkId);
|
||||
settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
|
||||
settlement.setSyncedTime(LocalDateTime.now());
|
||||
updateById(settlement);
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, "");
|
||||
log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}",
|
||||
settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId);
|
||||
return billNo;
|
||||
}
|
||||
|
||||
|
||||
+76
-3
@@ -52,6 +52,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
@@ -94,8 +95,10 @@ import org.springblade.transport.service.IInvoiceApplicationService;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeArBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
|
||||
import org.springblade.transport.wrapper.InvoiceApplicationWrapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springblade.core.tool.utils.SpringUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -149,6 +152,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
private final BfiProperties bfiProperties;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
|
||||
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
|
||||
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
|
||||
|
||||
/**
|
||||
@@ -167,7 +171,30 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
.eq(Func.isNotEmpty(query.getKingdeeStatus()), InvoiceApplication::getKingdeeStatus, query.getKingdeeStatus())
|
||||
.eq(Func.isNotEmpty(query.getApprovalStatus()), InvoiceApplication::getApprovalStatus, query.getApprovalStatus())
|
||||
.orderByDesc(InvoiceApplication::getCreateTime);
|
||||
return page(page, wrapper).convert(this::toListVO);
|
||||
IPage<InvoiceApplicationVO> voPage = page(page, wrapper).convert(this::toListVO);
|
||||
fillAttachmentSyncStatus(voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
|
||||
*
|
||||
* @param rows 本页 VO 列表
|
||||
*/
|
||||
private void fillAttachmentSyncStatus(List<InvoiceApplicationVO> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
|
||||
KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, rows.stream().map(InvoiceApplicationVO::getId).toList());
|
||||
for (InvoiceApplicationVO vo : rows) {
|
||||
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
|
||||
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
|
||||
successCounts.getOrDefault(vo.getId(), 0));
|
||||
vo.setAttachmentCount(attachmentCount);
|
||||
vo.setAttachmentSyncStatus(aggregateStatus);
|
||||
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -479,11 +506,47 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String syncKingdee(Long id) {
|
||||
// 单据级事务经自代理调用:单条失败只回滚自身
|
||||
String billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id);
|
||||
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
|
||||
syncAttachmentsAfterPush(id);
|
||||
return billNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):主单推送成功(含 A303-4 勾稽回填)后调用,以本地 kingdeeBillId 定位金蝶单据;
|
||||
* 本地缺失(存量数据)跳过待重推补传;附件上传异常独立捕获,绝不影响主单同步结果
|
||||
*
|
||||
* @param id 开票申请id
|
||||
*/
|
||||
private void syncAttachmentsAfterPush(Long id) {
|
||||
try {
|
||||
InvoiceApplication entity = getById(id);
|
||||
if (entity == null || Func.isBlank(entity.getKingdeeBillId())) {
|
||||
log.warn("开票申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", entity == null ? id : entity.getApplicationNo());
|
||||
return;
|
||||
}
|
||||
KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncUserAttachments(
|
||||
KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
|
||||
if (summary.total() > 0) {
|
||||
// 附件同步操作流水(Q5 决策:开票申请有 actionType 流水表,追加 attachment_upload 记录)
|
||||
record(id, "attachment_upload", "附件上传金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(),
|
||||
summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons())
|
||||
: "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)",
|
||||
entity.getKingdeeBillNo());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("开票申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String doSyncKingdee(Long id) {
|
||||
InvoiceApplication entity = existing(id);
|
||||
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
|
||||
// 已同步/已开票幂等返回(开票完成为终态)
|
||||
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
|
||||
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|
||||
|| KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) {
|
||||
return entity.getKingdeeBillNo();
|
||||
@@ -545,6 +608,16 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
if (existApply != null && Func.isNotBlank(existApply.getBillno())
|
||||
&& sheetBillNos.contains(existApply.getBillno())) {
|
||||
existSheetBillNos.add(existApply.getBillno());
|
||||
// 金蝶已生成:直接回填视为同步成功(勾稽纠偏),星瀚内码一并回填(A902 附件上传定位用)
|
||||
log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),跳过重复推送直接回填",
|
||||
entity.getApplicationNo(), existApply.getBillno(), existApply.getId());
|
||||
entity.setKingdeeBillNo(existApply.getBillno());
|
||||
entity.setKingdeeBillId(existApply.getId() == null ? null : String.valueOf(existApply.getId()));
|
||||
entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
|
||||
entity.setSyncedTime(LocalDateTime.now());
|
||||
updateById(entity);
|
||||
record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, existApply.getBillno());
|
||||
return existApply.getBillno();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+243
-17
@@ -28,14 +28,18 @@ package org.springblade.transport.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import feign.Response;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.core.tool.utils.WebUtil;
|
||||
import org.springblade.thirdparty.bfi.constant.BfiConstant;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO;
|
||||
@@ -44,6 +48,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.CustomerArchiveMapper;
|
||||
@@ -74,6 +79,7 @@ import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
|
||||
import org.springblade.transport.service.IInvoiceReceiptService;
|
||||
import org.springblade.transport.service.KingdeeSyncContext;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeReceivedInvoiceSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
|
||||
@@ -81,12 +87,18 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -111,6 +123,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
/** A801-1 rim 契约单页最大条数 */
|
||||
private static final int POOL_PAGE_SIZE = 500;
|
||||
|
||||
/** A801-2 文件下载流式拷贝缓冲区大小 */
|
||||
private static final int DOWNLOAD_BUFFER_SIZE = 8 * 1024;
|
||||
|
||||
private final KingdeeInvoicePoolMapper invoicePoolMapper;
|
||||
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
|
||||
private final InvoiceReceiptRecordMapper recordMapper;
|
||||
@@ -123,6 +138,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
private final IBfiService bfiService;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
|
||||
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
|
||||
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
|
||||
|
||||
/** 金蝶收票单结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
|
||||
@@ -155,7 +171,30 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
.eq(Func.isNotEmpty(query.getKingdeePushStatus()), InvoiceReceipt::getKingdeePushStatus,
|
||||
query.getKingdeePushStatus())
|
||||
.orderByDesc(InvoiceReceipt::getCreateTime);
|
||||
return page(page, wrapper).convert(this::toListVO);
|
||||
IPage<InvoiceReceiptVO> voPage = page(page, wrapper).convert(this::toListVO);
|
||||
fillAttachmentSyncStatus(voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
|
||||
*
|
||||
* @param rows 本页 VO 列表
|
||||
*/
|
||||
private void fillAttachmentSyncStatus(List<InvoiceReceiptVO> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
|
||||
KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, rows.stream().map(InvoiceReceiptVO::getId).toList());
|
||||
for (InvoiceReceiptVO vo : rows) {
|
||||
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
|
||||
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
|
||||
successCounts.getOrDefault(vo.getId(), 0));
|
||||
vo.setAttachmentCount(attachmentCount);
|
||||
vo.setAttachmentSyncStatus(aggregateStatus);
|
||||
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -707,6 +746,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
+ "未生成财务应付单");
|
||||
}
|
||||
String billNo;
|
||||
String billPkId;
|
||||
BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId()));
|
||||
if (kingdeeRow != null) {
|
||||
if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(kingdeeRow.getInvoicestatus())) {
|
||||
@@ -715,11 +755,15 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
+ "),请先在金蝶处理后再重推");
|
||||
log.warn("收票登记[{}]推送金蝶收票单拦截:{},金蝶单据编号={}", receipt.getInvoiceNo(), abnormalReason,
|
||||
kingdeeRow.getBillno());
|
||||
applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
abnormalReason, true);
|
||||
applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(),
|
||||
kingdeeRow.getId() == null ? null : String.valueOf(kingdeeRow.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED, abnormalReason, true);
|
||||
throw new ServiceException(abnormalReason);
|
||||
}
|
||||
billNo = kingdeeRow.getBillno();
|
||||
// 幂等预检命中行携带星瀚收票单内码,回填补齐(A902 附件上传定位用)
|
||||
billPkId = kingdeeRow.getId() == null ? receipt.getKingdeeReceivedInvoiceId()
|
||||
: String.valueOf(kingdeeRow.getId());
|
||||
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo);
|
||||
} else {
|
||||
try {
|
||||
@@ -734,32 +778,70 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条
|
||||
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups =
|
||||
kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context);
|
||||
billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
|
||||
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}]", receipt.getInvoiceNo(), billNo);
|
||||
KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome =
|
||||
kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
|
||||
billNo = pushOutcome.billNo();
|
||||
billPkId = pushOutcome.billId();
|
||||
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId);
|
||||
} catch (RuntimeException pushException) {
|
||||
log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage());
|
||||
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(),
|
||||
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
|
||||
throw pushException;
|
||||
}
|
||||
}
|
||||
// 保存成功先落单号+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
|
||||
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
|
||||
// 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付
|
||||
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false);
|
||||
// A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传):
|
||||
// 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传
|
||||
syncAttachmentsAfterPush(receipt, plan, billPkId);
|
||||
try {
|
||||
log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode);
|
||||
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
|
||||
} catch (RuntimeException assignException) {
|
||||
log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage());
|
||||
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true);
|
||||
throw assignException;
|
||||
}
|
||||
applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
|
||||
applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
|
||||
log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo);
|
||||
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
|
||||
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
|
||||
*
|
||||
* @param receipt 收票登记
|
||||
* @param plan 推送计划(镜像附件取数来源)
|
||||
* @param billPkId 星瀚收票单内码
|
||||
*/
|
||||
private void syncAttachmentsAfterPush(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan, String billPkId) {
|
||||
try {
|
||||
if (Func.isBlank(billPkId)) {
|
||||
log.warn("收票登记[{}]金蝶附件联带上传跳过:星瀚收票单内码未落库,待重推补传", receipt.getInvoiceNo());
|
||||
return;
|
||||
}
|
||||
List<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> items = new ArrayList<>();
|
||||
items.addAll(kingdeeAttachmentSyncSupport.parseUserAttachments(receipt.getAttachmentsJson()));
|
||||
// 镜像发票文件:幂等键=path,经 A801-2 下载后 A902 回传收票单(path 变化视为新文件自动重传)
|
||||
items.addAll(kingdeeAttachmentSyncSupport.parseInvoicePoolFile(plan.mirror().getAttachmentsJson()));
|
||||
KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncAttachments(
|
||||
KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, receipt.getId(), billPkId, items);
|
||||
if (summary.total() > 0 || summary.skipCount() > 0) {
|
||||
// 附件同步操作流水(Q5 决策:收票登记有 actionType 流水表,追加 attachment_upload 记录)
|
||||
record(receipt.getId(), "attachment_upload", "附件上传金蝶", receipt.getApprovalStatus(), receipt.getApprovalStatus(),
|
||||
summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons())
|
||||
: "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)",
|
||||
receipt.getKingdeeReceivedInvoiceNo());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("收票登记[{}]金蝶附件联带上传异常(不影响推送状态,重推自动补传)", receipt.getInvoiceNo(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因)
|
||||
*/
|
||||
@@ -827,11 +909,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送/回写结果落库:回写金蝶收票单号、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
|
||||
* 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水
|
||||
*/
|
||||
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String pushStatus,
|
||||
String pushMsg, boolean writeRecord) {
|
||||
private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String receivedInvoiceId,
|
||||
String pushStatus, String pushMsg, boolean writeRecord) {
|
||||
receipt.setKingdeeReceivedInvoiceNo(receivedInvoiceNo);
|
||||
if (Func.isNotBlank(receivedInvoiceId)) {
|
||||
receipt.setKingdeeReceivedInvoiceId(receivedInvoiceId);
|
||||
}
|
||||
receipt.setKingdeePushStatus(pushStatus);
|
||||
receipt.setKingdeePushMsg(pushMsg);
|
||||
receipt.setKingdeePushTime(LocalDateTime.now());
|
||||
@@ -941,7 +1026,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
try {
|
||||
if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(row.getInvoicestatus())) {
|
||||
applyReceivedInvoicePushResult(receipt, Func.toStrWithEmpty(row.getBillno(),
|
||||
receipt.getKingdeeReceivedInvoiceNo()), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
receipt.getKingdeeReceivedInvoiceNo()),
|
||||
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("金蝶侧收票单发票状态异常(" + Func.toStrWithEmpty(row.getInvoicestatusTitle(),
|
||||
row.getInvoicestatus()) + "),已回收推送状态,请在金蝶删除/解除指定后重推"), true);
|
||||
recovered++;
|
||||
@@ -955,6 +1042,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
// assigned 终态仅补登缺失的金蝶收票单号(保存成功但响应丢失的历史数据)
|
||||
if (StringUtil.isBlank(receipt.getKingdeeReceivedInvoiceNo()) && Func.isNotBlank(row.getBillno())) {
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(),
|
||||
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, false);
|
||||
filled++;
|
||||
}
|
||||
@@ -975,18 +1063,24 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
/** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */
|
||||
private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) {
|
||||
if (StringUtil.isBlank(apBillSrcCode)) {
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(),
|
||||
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true);
|
||||
throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付");
|
||||
}
|
||||
try {
|
||||
kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode);
|
||||
} catch (RuntimeException assignException) {
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(),
|
||||
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("收票单指定应付补偿失败:" + assignException.getMessage()), true);
|
||||
throw assignException;
|
||||
}
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
|
||||
applyReceivedInvoicePushResult(receipt, row.getBillno(),
|
||||
row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true);
|
||||
}
|
||||
|
||||
/** 回写补偿用:批量组装 pushed 单据的财务应付单防重键(不满足一票一应付的登记不返回该键) */
|
||||
@@ -1144,6 +1238,138 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
return JsonUtil.toJson(attachment);
|
||||
}
|
||||
|
||||
// ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ====================
|
||||
|
||||
@Override
|
||||
public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) {
|
||||
String trimmedInvoiceNo = trimOrNull(invoiceNo);
|
||||
if (StringUtil.isBlank(trimmedInvoiceNo)) {
|
||||
WebUtil.renderJson(response, R.fail("发票号不能为空"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doDownloadPoolAttachment(trimmedInvoiceNo, response);
|
||||
} catch (ServiceException exception) {
|
||||
// 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件
|
||||
log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage());
|
||||
WebUtil.renderJson(response, R.fail(exception.getMessage()));
|
||||
} catch (IOException exception) {
|
||||
// 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志
|
||||
log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流
|
||||
* → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆
|
||||
*/
|
||||
private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException {
|
||||
KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo);
|
||||
if (item == null || Func.isBlank(item.key())) {
|
||||
throw new ServiceException("该发票没有金蝶侧文件");
|
||||
}
|
||||
long start = System.currentTimeMillis();
|
||||
Response bfiResponse = bfiService.downloadInvoice(item.key());
|
||||
try {
|
||||
String contentType = firstHeader(bfiResponse, "Content-Type");
|
||||
// A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流
|
||||
if (contentType != null && contentType.contains("application/json")) {
|
||||
throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse));
|
||||
}
|
||||
String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name();
|
||||
// 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码
|
||||
String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType);
|
||||
String contentLength = firstHeader(bfiResponse, "Content-Length");
|
||||
if (contentLength != null) {
|
||||
try {
|
||||
response.setContentLengthLong(Long.parseLong(contentLength));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 非法长度头按无长度处理,走分块传输
|
||||
}
|
||||
}
|
||||
response.setHeader("Content-Disposition",
|
||||
"attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);
|
||||
int totalBytes = 0;
|
||||
try (InputStream inputStream = bfiResponse.body().asInputStream();
|
||||
OutputStream outputStream = response.getOutputStream()) {
|
||||
byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
totalBytes += read;
|
||||
}
|
||||
outputStream.flush();
|
||||
}
|
||||
log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms",
|
||||
invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start);
|
||||
} finally {
|
||||
bfiResponse.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
|
||||
* 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl;
|
||||
* 远程票号查询为模糊匹配,命中行需与目标票号全等防御
|
||||
*/
|
||||
private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) {
|
||||
List<KingdeeInvoicePool> mirrors = invoicePoolMapper.selectList(Wrappers.<KingdeeInvoicePool>lambdaQuery()
|
||||
.eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo));
|
||||
if (Func.isNotEmpty(mirrors)) {
|
||||
Optional<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> mirrorItem = kingdeeAttachmentSyncSupport
|
||||
.parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst();
|
||||
if (mirrorItem.isPresent()) {
|
||||
return mirrorItem.get();
|
||||
}
|
||||
}
|
||||
KingdeeInvoicePoolQueryDTO query = new KingdeeInvoicePoolQueryDTO();
|
||||
query.setInvoiceNo(invoiceNo);
|
||||
List<BfiInvoicePoolVO> rows = queryPoolPage(1, POOL_PAGE_SIZE, query);
|
||||
for (BfiInvoicePoolVO row : rows) {
|
||||
if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) {
|
||||
return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */
|
||||
private String readBfiJsonMessage(Response response) {
|
||||
try (InputStream inputStream = response.body().asInputStream()) {
|
||||
Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class);
|
||||
if (parsed instanceof Map<?, ?> map) {
|
||||
String message = firstTextValue(map, "message");
|
||||
if (Func.isNotBlank(message)) {
|
||||
return message;
|
||||
}
|
||||
String errorCode = firstTextValue(map, "errorCode");
|
||||
if (Func.isNotBlank(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.warn("解析BFI文件下载错误响应失败", exception);
|
||||
}
|
||||
return "响应体为JSON错误";
|
||||
}
|
||||
|
||||
/** 大小写不敏感取第一个响应头值 */
|
||||
private String firstHeader(Response response, String name) {
|
||||
for (Map.Entry<String, Collection<String>> entry : response.headers().entrySet()) {
|
||||
if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) {
|
||||
return entry.getValue().iterator().next();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstTextValue(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束)
|
||||
*/
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper;
|
||||
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
|
||||
import org.springblade.transport.service.IKingdeeAttachmentSyncService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 金蝶附件同步服务实现
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class KingdeeAttachmentSyncServiceImpl extends BaseServiceImpl<KingdeeAttachmentSyncMapper, KingdeeAttachmentSync>
|
||||
implements IKingdeeAttachmentSyncService {
|
||||
}
|
||||
+55
-4
@@ -47,13 +47,13 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.BillLedgerMapper;
|
||||
import org.springblade.transport.mapper.BillLedgerUsageMapper;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.CustomerArchiveMapper;
|
||||
import org.springblade.transport.mapper.CustomerReceiptAccountMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementPaymentMapper;
|
||||
@@ -95,6 +95,7 @@ import org.springblade.transport.service.IPaymentApplicationService;
|
||||
import org.springblade.transport.service.KingdeeSyncContext;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springblade.transport.support.KingdeeAttachmentSyncSupport;
|
||||
import org.springblade.transport.wrapper.PaymentApplicationWrapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -140,13 +141,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
private final ProjectApplyMapper projectApplyMapper;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final CustomerArchiveMapper customerArchiveMapper;
|
||||
private final CustomerReceiptAccountMapper receiptAccountMapper;
|
||||
private final CustomerReceiptAccountMapper receiptAccountMapper;
|
||||
private final BillLedgerMapper billLedgerMapper;
|
||||
private final BillLedgerUsageMapper billLedgerUsageMapper;
|
||||
private final TemporaryCreditLimitMapper temporaryCreditLimitMapper;
|
||||
private final IFormalSettlementService formalSettlementService;
|
||||
private final IBfiService bfiService;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
|
||||
private final BfiProperties bfiProperties;
|
||||
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
|
||||
|
||||
@@ -171,11 +173,35 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
|
||||
.ge(query.getApplyStartDate() != null, PaymentApplication::getApplyDate, query.getApplyStartDate())
|
||||
.le(query.getApplyEndDate() != null, PaymentApplication::getApplyDate, query.getApplyEndDate())
|
||||
.orderByDesc(PaymentApplication::getCreateTime);
|
||||
return page(page, wrapper).convert(item -> {
|
||||
IPage<PaymentApplicationVO> voPage = page(page, wrapper).convert(item -> {
|
||||
PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(item);
|
||||
if (!TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) vo.setPaidAmount(BigDecimal.ZERO);
|
||||
return vo;
|
||||
});
|
||||
fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1
|
||||
*
|
||||
* @param bizType 业务单据类型
|
||||
* @param rows 本页 VO 列表
|
||||
*/
|
||||
private void fillAttachmentSyncStatus(String bizType, List<PaymentApplicationVO> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, Integer> successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(bizType,
|
||||
rows.stream().map(PaymentApplicationVO::getId).toList());
|
||||
for (PaymentApplicationVO vo : rows) {
|
||||
int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson());
|
||||
String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount,
|
||||
successCounts.getOrDefault(vo.getId(), 0));
|
||||
vo.setAttachmentCount(attachmentCount);
|
||||
vo.setAttachmentSyncStatus(aggregateStatus);
|
||||
vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -373,9 +399,10 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
|
||||
|
||||
@Override
|
||||
public String syncKingdee(Long id, KingdeeSyncContext context) {
|
||||
String kingdeeBillId;
|
||||
try {
|
||||
// 单据级事务经自代理调用:单条失败只回滚自身,避免批量同步共用大事务长持锁
|
||||
return self().doSyncKingdee(id, context);
|
||||
kingdeeBillId = self().doSyncKingdee(id, context);
|
||||
} catch (ServiceException exception) {
|
||||
log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception);
|
||||
// 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态
|
||||
@@ -386,6 +413,30 @@ private final CustomerReceiptAccountMapper receiptAccountMapper;
|
||||
self().markKingdeeSyncFailed(id);
|
||||
throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage());
|
||||
}
|
||||
// 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传
|
||||
syncAttachmentsAfterPush(id);
|
||||
return kingdeeBillId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,
|
||||
* 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传;
|
||||
* 附件上传异常独立捕获,绝不影响主单同步结果
|
||||
*
|
||||
* @param id 付款申请id
|
||||
*/
|
||||
private void syncAttachmentsAfterPush(Long id) {
|
||||
try {
|
||||
PaymentApplication entity = getById(id);
|
||||
if (entity == null || Func.isBlank(entity.getKingdeeBillId())) {
|
||||
log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id);
|
||||
return;
|
||||
}
|
||||
kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
|
||||
id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
|
||||
} catch (Exception exception) {
|
||||
log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+33
-3
@@ -17,6 +17,7 @@
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
@@ -171,6 +172,32 @@ public class KingdeeApBillSyncSupport {
|
||||
return existBillNos;
|
||||
}
|
||||
|
||||
/**
|
||||
* A102-4 单防重键查询已生成财务应付单的星瀚内码(A902 附件上传定位用):
|
||||
* 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填
|
||||
*
|
||||
* @param awrpSrcCode 财务应付单防重键
|
||||
* @return 星瀚应付单内码,未生成返回 null
|
||||
*/
|
||||
public String queryApBillExistId(String awrpSrcCode) {
|
||||
if (Func.isBlank(awrpSrcCode)) {
|
||||
return null;
|
||||
}
|
||||
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
|
||||
param.setAwrpSrcCode(List.of(awrpSrcCode));
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(PAY_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryApBill(param);
|
||||
})) {
|
||||
if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && apBill.getId() != null) {
|
||||
return String.valueOf(apBill.getId());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== A102-2 财务应付单保存 ====================
|
||||
|
||||
/**
|
||||
@@ -196,7 +223,7 @@ public class KingdeeApBillSyncSupport {
|
||||
queryApBillExistNos(List.of(code)).getOrDefault(code, ""));
|
||||
if (Func.isNotEmpty(existBillNo)) {
|
||||
log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo);
|
||||
return new BfiApBillPushOutcome(existBillNo, true);
|
||||
return new BfiApBillPushOutcome(existBillNo, null, true);
|
||||
}
|
||||
ContractManage contract = requireContract(settlement.getContractId(), context);
|
||||
// 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可
|
||||
@@ -370,7 +397,9 @@ public class KingdeeApBillSyncSupport {
|
||||
requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单");
|
||||
String billNo = saveResult.getResult().get(0).getNumber();
|
||||
log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size());
|
||||
return new BfiApBillPushOutcome(billNo, false);
|
||||
// 星瀚应付单内码随结果返回(A902 附件上传定位用)
|
||||
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
|
||||
return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
|
||||
}
|
||||
|
||||
// ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
|
||||
@@ -887,8 +916,9 @@ public class KingdeeApBillSyncSupport {
|
||||
* 财务应付单(A102-2)推送结果
|
||||
*
|
||||
* @param billNo 财务应付单单据编号(金蝶已存在时返回已有单据编号)
|
||||
* @param billId 财务应付单星瀚内码(A902 附件上传定位用;金蝶已存在幂等跳过时为空,经 A102-4 查询回填)
|
||||
* @param alreadyExist 金蝶是否已存在该防重键的财务应付单,true 表示本次幂等跳过保存(上次同步已在后续环节中断)
|
||||
*/
|
||||
public record BfiApBillPushOutcome(String billNo, boolean alreadyExist) {
|
||||
public record BfiApBillPushOutcome(String billNo, String billId, boolean alreadyExist) {
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -27,6 +27,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
@@ -111,6 +112,32 @@ public class KingdeeArBillSyncSupport {
|
||||
return existBillNos;
|
||||
}
|
||||
|
||||
/**
|
||||
* A302-4 单防重键查询已生成财务应收单的星瀚内码(A902 附件上传定位用):
|
||||
* 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填
|
||||
*
|
||||
* @param arBillSrcCode 财务应收单防重键
|
||||
* @return 星瀚应收单内码,未生成返回 null
|
||||
*/
|
||||
public String queryArBillExistId(String arBillSrcCode) {
|
||||
if (Func.isBlank(arBillSrcCode)) {
|
||||
return null;
|
||||
}
|
||||
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
|
||||
param.setAwrpSrcCode(List.of(arBillSrcCode));
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(AR_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryArBill(param);
|
||||
})) {
|
||||
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && arBill.getId() != null) {
|
||||
return String.valueOf(arBill.getId());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== A302-2 财务应收单保存 ====================
|
||||
|
||||
/**
|
||||
@@ -135,7 +162,7 @@ public class KingdeeArBillSyncSupport {
|
||||
queryArBillExistNos(List.of(code)).getOrDefault(code, ""));
|
||||
if (Func.isNotEmpty(existBillNo)) {
|
||||
log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo);
|
||||
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, true);
|
||||
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, null, true);
|
||||
}
|
||||
ContractManage contract = kingdeeApBillSyncSupport.requireContract(settlement.getContractId(), context);
|
||||
// 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可
|
||||
@@ -269,7 +296,10 @@ public class KingdeeArBillSyncSupport {
|
||||
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单");
|
||||
String billNo = saveResult.getResult().get(0).getNumber();
|
||||
log.info("金蝶财务应收单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, entry.size());
|
||||
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(billNo, false);
|
||||
// 星瀚应收单内码随结果返回(A902 附件上传定位用)
|
||||
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
|
||||
return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(savedItem.getNumber(),
|
||||
savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false);
|
||||
}
|
||||
|
||||
// ==================== 结算明细分录解析 ====================
|
||||
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import feign.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper;
|
||||
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 金蝶附件同步支撑组件(A902 附件上传到指定单据)。
|
||||
* <p>设计不变式:
|
||||
* 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用);
|
||||
* 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
|
||||
* 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
|
||||
* 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。</p>
|
||||
* <p>附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
|
||||
* 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
|
||||
* path 变化(红冲重开)视为新文件自动触发重传)。</p>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class KingdeeAttachmentSyncSupport {
|
||||
|
||||
/** 用户附件下载连接/请求超时 */
|
||||
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60);
|
||||
|
||||
private final IBfiService bfiService;
|
||||
private final KingdeeAttachmentSyncMapper attachmentSyncMapper;
|
||||
|
||||
/** 附件下载客户端(无状态,进程内复用) */
|
||||
private final HttpClient downloadClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* 待同步附件项
|
||||
*
|
||||
* @param name 附件名称(含扩展名,展示/金蝶侧文件名)
|
||||
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path
|
||||
* @param size 附件大小(字节,可空:空时下载后按实际字节校验)
|
||||
* @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url
|
||||
*/
|
||||
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单据附件同步结果(各模块用于追加操作流水与前端提示)
|
||||
*
|
||||
* @param total 参与同步的附件数(不含已成功跳过)
|
||||
* @param success 本次上传成功数
|
||||
* @param failed 失败数
|
||||
* @param skipCount 已成功幂等跳过数
|
||||
* @param failReasons 失败原因明细(附件名:原因)
|
||||
*/
|
||||
public record AttachmentSyncSummary(int total, int success, int failed, int skipCount, List<String> failReasons) {
|
||||
}
|
||||
|
||||
// ==================== 附件同步主流程 ====================
|
||||
|
||||
/**
|
||||
* 同步单据附件至金蝶对应单据附件面板(A902,逐附件独立 try/catch:成功落成功记录、失败落失败记录)。
|
||||
* billPkId 为空(星瀚单据内码未落库)时整单跳过并记日志,由后续重推补传。
|
||||
*
|
||||
* @param bizType 业务单据类型(KingdeeAttachmentConstant.BIZ_*)
|
||||
* @param bizId 业务单据id
|
||||
* @param billPkId 星瀚单据内码
|
||||
* @param items 待同步附件列表
|
||||
* @return 同步结果汇总
|
||||
*/
|
||||
public AttachmentSyncSummary syncAttachments(String bizType, Long bizId, String billPkId, List<KingdeeAttachmentItem> items) {
|
||||
if (Func.isEmpty(items)) {
|
||||
return new AttachmentSyncSummary(0, 0, 0, 0, List.of());
|
||||
}
|
||||
if (Func.isBlank(billPkId)) {
|
||||
log.warn("金蝶附件同步跳过:业务单据[{}:{}]星瀚单据内码为空,待重推补传", bizType, bizId);
|
||||
return new AttachmentSyncSummary(items.size(), 0, 0, 0, List.of());
|
||||
}
|
||||
// 载入已成功集合:命中即幂等跳过(重推主单时 A902 零调用)
|
||||
Set<String> successKeys = successKeys(bizType, bizId);
|
||||
// 载入既有同步记录(含失败记录,重试成功时覆盖为成功)
|
||||
Map<String, KingdeeAttachmentSync> existingByKey = existingByKey(bizType, bizId);
|
||||
String entityNumber = KingdeeAttachmentConstant.entityNumber(bizType);
|
||||
log.info("金蝶附件同步开始:业务单据[{}:{}],星瀚内码={},entityNumber={},待同步附件 {} 个,历史已成功 {} 个",
|
||||
bizType, bizId, billPkId, entityNumber, items.size(), successKeys.size());
|
||||
int success = 0;
|
||||
int skipCount = 0;
|
||||
List<String> failReasons = new ArrayList<>();
|
||||
List<KingdeeAttachmentSync> pendingRecords = new ArrayList<>();
|
||||
for (KingdeeAttachmentItem item : items) {
|
||||
if (Func.isBlank(item.key())) {
|
||||
continue;
|
||||
}
|
||||
if (successKeys.contains(item.key())) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 声明大小超限直接拦截,不发起下载
|
||||
if (item.size() != null && item.size() > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("附件超过20MB大小限制("
|
||||
+ formatSize(item.size()) + "),请压缩后重试");
|
||||
}
|
||||
byte[] content = fetchContent(item);
|
||||
if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("附件超过20MB大小限制("
|
||||
+ formatSize((long) content.length) + "),请压缩后重试");
|
||||
}
|
||||
String fileName = Func.isBlank(item.name()) ? "attachment" : item.name();
|
||||
BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId,
|
||||
KingdeeAttachmentConstant.CONTROL_KEY, fileName, content);
|
||||
log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath());
|
||||
pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS,
|
||||
null, uploadVO.getPath(), existingByKey.get(item.key())));
|
||||
success++;
|
||||
} catch (Exception exception) {
|
||||
String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
|
||||
log.warn("金蝶附件同步失败:业务单据[{}:{}],附件[{}]", bizType, bizId, item.name(), exception);
|
||||
failReasons.add(Func.toStr(item.name()) + ":" + reason);
|
||||
pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_FAILED,
|
||||
truncateReason(reason), null, existingByKey.get(item.key())));
|
||||
}
|
||||
}
|
||||
for (KingdeeAttachmentSync record : pendingRecords) {
|
||||
if (record.getId() == null) {
|
||||
attachmentSyncMapper.insert(record);
|
||||
} else {
|
||||
// updateById 默认忽略 null 字段,失败转成功时 fail_reason 须显式置空,避免残留历史失败原因
|
||||
attachmentSyncMapper.update(record, Wrappers.<KingdeeAttachmentSync>lambdaUpdate()
|
||||
.eq(KingdeeAttachmentSync::getId, record.getId())
|
||||
.set(KingdeeAttachmentSync::getFailReason, record.getFailReason()));
|
||||
}
|
||||
}
|
||||
int total = success + failReasons.size();
|
||||
log.info("金蝶附件同步完成:业务单据[{}:{}],本次待传 {},成功 {},失败 {},历史已传跳过 {}",
|
||||
bizType, bizId, total, success, failReasons.size(), skipCount);
|
||||
return new AttachmentSyncSummary(total, success, failReasons.size(), skipCount, failReasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步用户附件(单据 attachmentsJson):解析后逐个上传
|
||||
*
|
||||
* @param bizType 业务单据类型
|
||||
* @param bizId 业务单据id
|
||||
* @param billPkId 星瀚单据内码
|
||||
* @param attachmentsJson 单据附件JSON(数组,容忍单对象)
|
||||
* @return 同步结果汇总
|
||||
*/
|
||||
public AttachmentSyncSummary syncUserAttachments(String bizType, Long bizId, String billPkId, String attachmentsJson) {
|
||||
return syncAttachments(bizType, bizId, billPkId, parseUserAttachments(attachmentsJson));
|
||||
}
|
||||
|
||||
// ==================== 附件解析 ====================
|
||||
|
||||
/**
|
||||
* 解析单据用户附件(attachmentsJson):取 url/name/size,无 url 的项忽略。
|
||||
* 前端附件行字段容忍取值:名称 originalName/name/fileName,地址 url/link/fileUrl/downloadUrl/domain/src
|
||||
*
|
||||
* @param attachmentsJson 附件JSON
|
||||
* @return 附件项列表
|
||||
*/
|
||||
public List<KingdeeAttachmentItem> parseUserAttachments(String attachmentsJson) {
|
||||
Object parsed = parseJson(attachmentsJson);
|
||||
if (parsed == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
|
||||
List<KingdeeAttachmentItem> items = new ArrayList<>();
|
||||
for (Object row : rows) {
|
||||
if (!(row instanceof Map<?, ?> map)) {
|
||||
continue;
|
||||
}
|
||||
String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src");
|
||||
if (Func.isBlank(url)) {
|
||||
continue;
|
||||
}
|
||||
String name = firstText(map, "originalName", "name", "fileName");
|
||||
Long size = firstLong(map, "size");
|
||||
items.add(new KingdeeAttachmentItem(name, url, size, false));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析票池镜像发票文件(attachmentsJson 单对象 {source,path,name}):幂等键=path(经 A801-2 下载),
|
||||
* path 变化(红冲重开)自动视为新文件触发重传
|
||||
*
|
||||
* @param attachmentsJson 镜像附件JSON
|
||||
* @return 附件项列表(无发票文件时为空)
|
||||
*/
|
||||
public List<KingdeeAttachmentItem> parseInvoicePoolFile(String attachmentsJson) {
|
||||
Object parsed = parseJson(attachmentsJson);
|
||||
if (parsed == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
|
||||
List<KingdeeAttachmentItem> items = new ArrayList<>();
|
||||
for (Object row : rows) {
|
||||
if (!(row instanceof Map<?, ?> map)) {
|
||||
continue;
|
||||
}
|
||||
String path = firstText(map, "path");
|
||||
if (Func.isBlank(path)) {
|
||||
continue;
|
||||
}
|
||||
String name = firstText(map, "name");
|
||||
items.add(new KingdeeAttachmentItem(Func.isBlank(name) ? "发票文件" : name, path, null, true));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ==================== 列表聚合态 ====================
|
||||
|
||||
/**
|
||||
* 单据附件数量(列表聚合态判定用):解析失败按 0 处理
|
||||
*
|
||||
* @param attachmentsJson 单据附件JSON
|
||||
* @return 附件数量
|
||||
*/
|
||||
public int countAttachments(String attachmentsJson) {
|
||||
return parseUserAttachments(attachmentsJson).size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 各业务单据已成功上传附件数(一次 GROUP BY 查询,避免 N+1)
|
||||
*
|
||||
* @param bizType 业务单据类型
|
||||
* @param bizIds 业务单据id集合
|
||||
* @return 业务单据id -> 已成功附件数
|
||||
*/
|
||||
public Map<Long, Integer> successCountByBizId(String bizType, Collection<Long> bizIds) {
|
||||
if (Func.isEmpty(bizIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
QueryWrapper<KingdeeAttachmentSync> wrapper = new QueryWrapper<KingdeeAttachmentSync>()
|
||||
.select("biz_id", "count(*) AS success_count")
|
||||
.eq("biz_type", bizType)
|
||||
.eq("sync_status", KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS)
|
||||
.in("biz_id", bizIds)
|
||||
.groupBy("biz_id");
|
||||
Map<Long, Integer> result = new HashMap<>();
|
||||
for (Map<String, Object> row : attachmentSyncMapper.selectMaps(wrapper)) {
|
||||
Object bizId = row.get("biz_id");
|
||||
Object count = row.get("success_count");
|
||||
if (bizId != null && count != null) {
|
||||
result.put(Long.valueOf(String.valueOf(bizId)), Integer.parseInt(String.valueOf(count)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合态判定:无附件 / 未上传 / 部分未传 / 已上传(运行时聚合,非存储字段)
|
||||
*
|
||||
* @param attachmentCount 单据附件数量
|
||||
* @param successCount 已成功上传数量
|
||||
* @return 聚合态(KingdeeAttachmentConstant.AGGREGATE_*)
|
||||
*/
|
||||
public String aggregateStatus(int attachmentCount, int successCount) {
|
||||
if (attachmentCount <= 0) {
|
||||
return KingdeeAttachmentConstant.AGGREGATE_NONE;
|
||||
}
|
||||
if (successCount <= 0) {
|
||||
return KingdeeAttachmentConstant.AGGREGATE_PENDING;
|
||||
}
|
||||
return successCount >= attachmentCount
|
||||
? KingdeeAttachmentConstant.AGGREGATE_DONE : KingdeeAttachmentConstant.AGGREGATE_PARTIAL;
|
||||
}
|
||||
|
||||
// ==================== 内部实现 ====================
|
||||
|
||||
/** 已成功附件幂等键集合 */
|
||||
private Set<String> successKeys(String bizType, Long bizId) {
|
||||
return attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>lambdaQuery()
|
||||
.select(KingdeeAttachmentSync::getAttachmentKey)
|
||||
.eq(KingdeeAttachmentSync::getBizType, bizType)
|
||||
.eq(KingdeeAttachmentSync::getBizId, bizId)
|
||||
.eq(KingdeeAttachmentSync::getSyncStatus, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS))
|
||||
.stream().map(KingdeeAttachmentSync::getAttachmentKey).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** 既有同步记录(含失败),key -> 记录(重试成功时覆盖为成功) */
|
||||
private Map<String, KingdeeAttachmentSync> existingByKey(String bizType, Long bizId) {
|
||||
Map<String, KingdeeAttachmentSync> result = new LinkedHashMap<>();
|
||||
for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>lambdaQuery()
|
||||
.eq(KingdeeAttachmentSync::getBizType, bizType)
|
||||
.eq(KingdeeAttachmentSync::getBizId, bizId))) {
|
||||
result.putIfAbsent(record.getAttachmentKey(), record);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装同步记录:有既有记录时复用主键覆盖更新(失败转成功/成功后清理失败原因),否则新增
|
||||
*/
|
||||
private KingdeeAttachmentSync buildRecord(String bizType, Long bizId, KingdeeAttachmentItem item, int syncStatus,
|
||||
String failReason, String kingdeePath, KingdeeAttachmentSync existing) {
|
||||
KingdeeAttachmentSync record = existing == null ? new KingdeeAttachmentSync() : existing;
|
||||
record.setBizType(bizType);
|
||||
record.setBizId(bizId);
|
||||
record.setAttachmentKey(item.key());
|
||||
record.setAttachmentName(truncate(item.name(), 250));
|
||||
record.setKingdeePath(kingdeePath);
|
||||
record.setSyncStatus(syncStatus);
|
||||
record.setFailReason(failReason);
|
||||
record.setSyncTime(LocalDateTime.now());
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控);
|
||||
* 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
|
||||
*/
|
||||
private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception {
|
||||
if (item.kingdeePathSource()) {
|
||||
Response response = bfiService.downloadInvoice(item.key());
|
||||
try (InputStream inputStream = response.body().asInputStream()) {
|
||||
return readCapped(inputStream);
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
}
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(item.key()))
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<InputStream> response = downloadClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode());
|
||||
}
|
||||
try (InputStream inputStream = response.body()) {
|
||||
return readCapped(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
/** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */
|
||||
private byte[] readCapped(InputStream inputStream) throws IOException {
|
||||
int bufferSize = 64 * 1024;
|
||||
long limit = KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES;
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream(bufferSize);
|
||||
byte[] chunk = new byte[bufferSize];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(chunk)) != -1) {
|
||||
if (total + read > limit) {
|
||||
throw new IllegalArgumentException("附件超过20MB大小限制,请压缩后重试");
|
||||
}
|
||||
buffer.write(chunk, 0, read);
|
||||
total += read;
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private Object parseJson(String json) {
|
||||
if (Func.isBlank(json)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonUtil.parse(json.trim(), Object.class);
|
||||
} catch (Exception exception) {
|
||||
log.warn("附件JSON解析失败,按无附件处理:{}", exception.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 附件行字段容忍取值(多个候选键取第一个非空值) */
|
||||
private String firstText(Map<?, ?> map, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = map.get(key);
|
||||
if (value != null && Func.isNotBlank(String.valueOf(value))) {
|
||||
return String.valueOf(value).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long firstLong(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value != null) {
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text == null || text.length() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private String truncateReason(String reason) {
|
||||
return truncate(reason, 490);
|
||||
}
|
||||
|
||||
private String formatSize(Long bytes) {
|
||||
if (bytes == null) {
|
||||
return "未知";
|
||||
}
|
||||
return String.format("%.1fMB", bytes / 1024.0 / 1024.0);
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -35,6 +35,7 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
@@ -158,10 +159,10 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
* @param contract 分摊应付结算单关联合同(组织/币种/往来户派生)
|
||||
* @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果)
|
||||
* @param context 金蝶同步上下文(缓存载体)
|
||||
* @return 金蝶收票单单据编号
|
||||
* @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用)
|
||||
*/
|
||||
public String pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
|
||||
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
|
||||
public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract,
|
||||
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups, KingdeeSyncContext context) {
|
||||
if (Func.isBlank(mirror.getInvoiceTypeCode())) {
|
||||
throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票");
|
||||
}
|
||||
@@ -216,7 +217,18 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
|
||||
BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param);
|
||||
kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单");
|
||||
return saveResult.getResult().get(0).getNumber();
|
||||
BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0);
|
||||
return new ReceivedInvoicePushOutcome(savedItem.getNumber(),
|
||||
savedItem.getId() == null ? null : String.valueOf(savedItem.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A303-2 收票单推送结果
|
||||
*
|
||||
* @param billNo 金蝶收票单单据编号
|
||||
* @param billId 星瀚收票单内码(A902 附件上传定位用)
|
||||
*/
|
||||
public record ReceivedInvoicePushOutcome(String billNo, String billId) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
@@ -27,7 +27,12 @@ package org.springblade.thirdparty.bfi.config;
|
||||
|
||||
import feign.Request;
|
||||
import feign.RequestInterceptor;
|
||||
import feign.codec.Encoder;
|
||||
import feign.form.spring.SpringFormEncoder;
|
||||
import org.springblade.thirdparty.bfi.interceptor.BfiRequestInterceptor;
|
||||
import org.springframework.beans.factory.ObjectFactory;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
import org.springframework.cloud.openfeign.support.SpringEncoder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -61,4 +66,9 @@ public class BfiFeignClientConfig {
|
||||
public Request.Options options() {
|
||||
return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Encoder feignFormEncoder(ObjectFactory<HttpMessageConverters> messageConverters) {
|
||||
return new SpringFormEncoder(new SpringEncoder(messageConverters));
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
BfiResultVO<BfiAttachmentUploadVO> uploadAttachment(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestPart("attachmentUploadFileArgs") String attachmentUploadFileArgs);
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.thirdparty.bfi.pojo.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 业财融合(BFI)A902 附件上传返回数据
|
||||
* <p>
|
||||
* 响应示例:
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "data": {"path": "211e6ce5c9805c00"},
|
||||
* "errorCode": "0",
|
||||
* "status": true
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BfiAttachmentUploadVO implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
/**
|
||||
* 金蝶侧文件路径(附件面板回显/排查用)
|
||||
*/
|
||||
private String path;
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+34
@@ -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;
|
||||
@@ -533,12 +536,15 @@ public class BfiServiceImpl implements IBfiService {
|
||||
BfiInvoiceDownloadDTO param = new BfiInvoiceDownloadDTO();
|
||||
param.setPath(path);
|
||||
String accessToken = getAccessToken();
|
||||
long start = System.currentTimeMillis();
|
||||
log.info("调用BFI接口 进项发票下载开始 path:{}", path);
|
||||
try {
|
||||
Response response = client.invoiceDownload(accessToken, param);
|
||||
if (response == null || response.status() != 200) {
|
||||
log.error("调用BFI接口 进项发票下载异常响应状态:{}", response == null ? "无响应" : response.status());
|
||||
throw new BfiException("调用BFI接口进项发票下载异常,响应状态:" + (response == null ? "无响应" : response.status()));
|
||||
}
|
||||
log.info("调用BFI接口 进项发票下载结束 耗时:{}ms", System.currentTimeMillis() - start);
|
||||
return response;
|
||||
} catch (BfiException e) {
|
||||
throw e;
|
||||
@@ -548,6 +554,34 @@ 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);
|
||||
long start = System.currentTimeMillis();
|
||||
log.info("调用BFI接口 附件上传开始 entityNumber:{},billPkId:{},文件:{},大小:{}字节",
|
||||
entityNumber, billPkId, fileName, fileContent.length);
|
||||
BfiResultVO<BfiAttachmentUploadVO> 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接口附件上传异常,返回数据为空");
|
||||
}
|
||||
log.info("调用BFI接口 附件上传结束 耗时:{}ms,kingdeePath:{}", System.currentTimeMillis() - start, uploadVO.getPath());
|
||||
return uploadVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共调用方法,统一校验响应结果
|
||||
*
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
# 金蝶附件同步(A902)设计文档
|
||||
|
||||
- 日期:2026-09-24
|
||||
- 范围:后端同步链路联带 A902 附件上传(付款申请、正式结算应付/应收、收票登记、开票申请)+ 前端状态列/提示/超时调整
|
||||
- 背景:单据推送金蝶成功后,TMS 侧附件(attachmentsJson)不随单上行,金蝶单据缺影像。星瀚 A902「附件上传到指定单据」接口已具备(见《业财融合系统接口文档.xlsx》同名页签),此前未在 BFI 客户端实现
|
||||
- 前置:原计划复用 2026-09-24-kingdee-push-timeout-anticlick 的 loading+守卫+try/finally 模式;经评审确认该前置未落地,本期**不实施防连点(后补)**,本设计仍不新增前端状态机
|
||||
|
||||
## 一、目标
|
||||
|
||||
1. 五类单据推送金蝶成功后,后端联带逐个上传附件至金蝶对应单据附件面板(A902)
|
||||
2. 收票登记含发票文件:票池镜像附件 path → A801-2 下载 → A902 回传收票单
|
||||
3. 附件同步独立状态、失败可补传(重推自动补);前端列表列 + message 可见
|
||||
4. 客商档案预留 entityNumber 映射配置,二期接入(开票申请 entityNumber 已确认为 `sim_original_bill`,提入本期)
|
||||
|
||||
## 二、A902 / A801-2 契约
|
||||
|
||||
### A902 附件上传到指定单据
|
||||
|
||||
- `POST /ierp/kapi/v2/frame/attachment/uploadFile`,multipart/form-data
|
||||
- Header:`access_token`(复用现有 getToken/token 缓存)
|
||||
- Body:`file`(文件)+ `attachmentUploadFileArgs`(JSON):`{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"}`
|
||||
- 返回:`{"data":{"path":"211e6ce5c9805c00"},"errorCode":"0","status":true}`,`data.path` 即金蝶侧文件路径
|
||||
|
||||
### A801-2 文件下载(收票发票文件用)
|
||||
|
||||
- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `path`(镜像 attachmentsJson 的 path)→ 返回文件流
|
||||
|
||||
### entityNumber / controlKey 映射
|
||||
|
||||
| 业务单据 | entityNumber | controlKey | 期次 |
|
||||
| --- | --- | --- | --- |
|
||||
| 付款申请单 | `ap_payapply` | `attachmentpanel` | 本期 |
|
||||
| 财务应付单(正式结算应付面板) | `ap_finapbill` | `attachmentpanel` | 本期 |
|
||||
| 财务应收单(正式结算应收面板) | `ar_finarbill` | `attachmentpanel` | 本期 |
|
||||
| 收票单 | `ap_invoice` | `attachmentpanel` | 本期 |
|
||||
| 开票申请单 | `sim_original_bill` | `attachmentpanel` | 本期 |
|
||||
| 客商(资质附件) | 待金蝶确认 | 待确认 | 二期 |
|
||||
|
||||
映射以常量类维护:`KingdeeAttachmentConstant.ENTITY_NUMBERS`(biz_type → entityNumber),controlKey 统一 `attachmentpanel`;二期客商补条目不改架构。
|
||||
|
||||
## 三、领域模型
|
||||
|
||||
### 实体
|
||||
|
||||
**业务附件(不动)**:各单据 `attachmentsJson` JSON 列,是「单据有哪些附件」的业务事实,随表单编辑、随审批快照。本设计不读写其结构(只读 url/name)。
|
||||
|
||||
**附件同步表 `blade_kingdee_attachment_sync`(新增)**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| id / tenant_id / 审计字段 | 标准结构 |
|
||||
| biz_type | `pay_application` / `formal_settlement_ap` / `formal_settlement_ar` / `invoice_receipt` / `invoice_application` |
|
||||
| biz_id | 单据 id |
|
||||
| attachment_key | 幂等键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件可重传) |
|
||||
| attachment_name | 附件名(展示/排查用) |
|
||||
| kingdee_path | A902 返回的 data.path |
|
||||
| sync_status | 1 成功 / 0 失败 |
|
||||
| fail_reason | 失败原因(超限/下载失败/金蝶报错) |
|
||||
| sync_time | 上传时间 |
|
||||
|
||||
- 唯一键 `uk_(biz_type, biz_id, attachment_key)`;同步前按 `(biz_type, biz_id)` 载入成功集合,命中即跳过(幂等)
|
||||
- 同步流水落点见「四、同步流水落点(评审定案)」
|
||||
|
||||
**单据表加列**:`kingdee_bill_id`(星瀚单据内码,取自保存响应 `BfiBillSaveResultItemVO.id`)。付款申请已有该列;本期新增三列(SQL 见 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`):
|
||||
|
||||
| 表 | 列 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| blade_formal_settlement | kingdee_bill_id | A102-2/A302-2 保存响应返回;幂等跳过/存量缺失经 A102-4/A302-4 按防重键反查回填 |
|
||||
| blade_invoice_receipt | kingdee_received_invoice_id | A303-2 保存响应返回;A801-3 幂等预检命中/定时回写时一并回填 |
|
||||
| blade_invoice_application | kingdee_bill_id | A303-1 保存响应返回;A303-4 勾稽回填一并带回 |
|
||||
|
||||
### 附件同步状态机(单附件行级)
|
||||
|
||||
```
|
||||
(无记录) ──上传成功──> 成功(终态,幂等跳过)
|
||||
└────上传失败──> 失败(记 fail_reason;重推时重新尝试,成功则覆盖为成功)
|
||||
```
|
||||
|
||||
列表聚合展示态(运行时聚合,非存储字段,评审定案):`无附件` / `未上传` / `部分未传` / `已上传`。
|
||||
判定:单据 attachmentsJson 无可上传附件→无附件;同步表成功数=0→未上传;0<成功数<附件数→部分未传;成功数≥附件数→已上传。
|
||||
|
||||
### 不变式
|
||||
|
||||
1. 附件上传必须晚于主单推送成功、拿到并落库 billPkId 之后
|
||||
2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch)
|
||||
3. 同 `(biz_type, biz_id, attachment_key)` 最多一条成功记录
|
||||
4. 不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件补传分支)
|
||||
5. 单文件软限制 20MB(常量,待金蝶确认后调整);超限/下载失败记原因跳过,不阻塞同单其余附件,更不阻塞同批其他单据
|
||||
|
||||
## 四、后端设计
|
||||
|
||||
### BFI 客户端(blade-bfi-api)
|
||||
|
||||
`IBfiClient` 新增一个方法,复用现有 token 配置(`BfiFeignClientConfig`):
|
||||
|
||||
- `uploadAttachment`(A902):`produces = MULTIPART_FORM_DATA_VALUE`,`@RequestPart("file")`(内容为内存字节数组,经 `InMemoryMultipartFile` 适配,单文件软限制 20MB)+ `@RequestPart("attachmentUploadFileArgs")`(JSON 字符串),返回 `BfiResultVO<BfiAttachmentUploadVO>`,`data.path` 即金蝶侧文件路径
|
||||
- A801-2 下载**复用已有** `IBfiClient.invoiceDownload` / `IBfiService.downloadInvoice`(`feign.Response` 流式读取),本期仅在附件链路启用,无新增接口
|
||||
|
||||
### 附件同步支持类(blade-transport)
|
||||
|
||||
新增 `KingdeeAttachmentSyncSupport`(`org.springblade.transport.support`,与 `KingdeeApBillSyncSupport` 等同包同风格):
|
||||
|
||||
```
|
||||
syncAttachments(bizType, bizId, billPkId, List<KingdeeAttachmentItem>) // 主流程
|
||||
syncUserAttachments(bizType, bizId, billPkId, attachmentsJson) // 用户附件快捷入口
|
||||
parseUserAttachments(json) / parseInvoicePoolFile(json) // 解析:数组项容忍 url/link/fileUrl/downloadUrl/domain/src;镜像对象取 path
|
||||
countAttachments(json) / successCountByBizId(bizType, bizIds) // 列表聚合(一次 GROUP BY,防 N+1)
|
||||
aggregateStatus(attachmentCount, successCount) // 四态判定
|
||||
```
|
||||
|
||||
流程:载入已成功集合 → 过滤已传 → 过滤超限(记 fail_reason)→ 逐个(http(s) 下载 / A801-2 下载,流式封顶读取)→ A902 上传 → 落记录。单附件 try/catch,异常记失败继续下一个;结果汇总(`AttachmentSyncSummary`:total/success/failed/skipCount/failReasons)供接入点写操作流水与前端提示。同步表既有失败记录重试成功时覆盖为成功(复用主键 update)。
|
||||
|
||||
### 各模块接入点(主单成功、billPkId 落库之后,均在主单事务提交后执行)
|
||||
|
||||
| 模块 | 接入点 | entityNumber | 附件来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| 付款申请 | `PaymentApplicationServiceImpl.syncKingdee(id, context)` 包装层(`doSyncKingdee` 事务提交后) | ap_payapply | 单据 attachmentsJson |
|
||||
| 正式结算应付/应收 | `FormalSettlementServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee`(@Transactional)提交后 | ap_finapbill / ar_finarbill | 结算单 attachmentsJson |
|
||||
| 收票登记 | `pushOneReceivedInvoice`:A303-2 落 pushed 后、A302-6 指定应付前 | ap_invoice | 登记附件 + 镜像发票文件(A801-2 下载,key=path) |
|
||||
| 开票申请 | `InvoiceApplicationServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee` 提交后 | sim_original_bill | 单据 attachmentsJson |
|
||||
|
||||
事务边界(ADR-7):附件上传是慢远程调用,统一放在主单事务**之外**——正式结算/开票申请原 `syncKingdee` 事务体下移为 `doSyncKingdee`(经自代理调用),付款申请沿用既有包装层模式;附件失败仅在附件链路内消化,绝不回滚主单、不拉长主单持锁时间。
|
||||
|
||||
星瀚内码(billPkId)回填路径:
|
||||
|
||||
- 推送路径:保存/下推响应 `BfiBillSaveResultItemVO.id` 随单落库(`BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展携带 billId)
|
||||
- 幂等/存量路径:本地 kingdee_bill_id 缺失时反查——正式结算 A102-4/A302-4(`queryApBillExistId`/`queryArBillExistId`)、收票 A801-3 预检行/定时回写行自带 id、开票申请 A303-4 勾稽行自带 id;反查命中即回填并记 info 日志,仍未命中则跳过附件待重推补传
|
||||
|
||||
重推补传:现有同步入口无改动——主单被幂等预检判已存在时,仍进入附件补传分支(billPkId 取本地 kingdee_bill_id,缺失则按上述路径反查回填)。
|
||||
|
||||
### 同步流水落点(评审定案)
|
||||
|
||||
附件明细以 `blade_kingdee_attachment_sync` 自身为准(sync_time/fail_reason 已是完整流水);仅在**有 actionType 流水表的单据**追加 `attachment_upload` 操作记录:收票登记(`InvoiceReceiptRecord`)、开票申请(`InvoiceApplicationRecord`);付款申请/正式结算无流水表,不追加。
|
||||
|
||||
### 列表状态聚合
|
||||
|
||||
各列表 VO(PaymentApplicationVO/InvoiceApplicationVO/FormalSettlementVO/InvoiceReceiptVO)新增三个展示字段:`attachmentCount`、`attachmentSyncStatus`(none/pending/partial/done)、`attachmentSyncStatusName`(无附件/未上传/部分未传/已上传)。各 `selectPage` 转换后按本页 biz_ids 一次 `GROUP BY biz_id`(`selectMaps`)查同步表成功计数,与解析 attachmentsJson 的附件数对比得出聚合态,避免 N+1;正式结算按应付/应收拆两组业务类型各查一次。
|
||||
|
||||
## 五、前端设计
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 超时 | 实施勘误:paymentApplication/invoiceApplication/formalSettlement 的 syncKingdee 实际已是 120s(批量 300s),无需调整;唯一风险点是 `invoiceReceipt.js pushKingdee` 未设 timeout(走全局 10s 默认),已显式放宽为 120000 |
|
||||
| 列表列 | payment-application.vue、invoice-application.vue、formal-settlement.vue(option `formalSettlementTable.js`)、invoice-receipt.vue 增「附件同步」列(`attachmentSyncStatusName`):无附件/未上传/部分未传/已上传 |
|
||||
| message | 同步成功后 `await loadTable()` 刷新列表,按本次同步 ids 过滤刷新行中 `attachmentSyncStatus ∈ {pending, partial}` 的数量,追加 warning 提示「其中 N 单附件未全部上传,可重推补传」;收票页追加进推送结果弹窗文案 |
|
||||
| 交互 | 无新增按钮(重推即补传);loading/守卫/try-finally 防连点模式本期未实施(见「不做的事」) |
|
||||
|
||||
## 六、不做的事
|
||||
|
||||
- 前端同步按钮防连点模式(loading+守卫+try/finally):本期评审确认不实施,后补
|
||||
- 客商档案附件推送(二期,等金蝶确认 entityNumber/controlKey;本期仅预留映射配置 `KingdeeAttachmentConstant.ENTITY_NUMBERS`)
|
||||
- 附件自动重试、定时补传任务
|
||||
- attachmentsJson 结构改造(去 JSON 化属独立重构,见 ADR-3)
|
||||
- OFD/PDF 发票文件在金蝶侧的可预览性验证
|
||||
|
||||
## 七、待确认(金蝶侧)
|
||||
|
||||
1. A902 单文件大小上限、允许格式(当前后端软限制 20MB 常量)
|
||||
2. 客商的 entityNumber/controlKey
|
||||
3. A902 调用频率限制(批量串行是否需间隔)
|
||||
4. OFD 数电票文件上传收票单附件面板是否可用(主文档 641 行注明「金蝶不支持直接推送数电票 OFD 附件」,需验证 A902 通道例外与否)
|
||||
|
||||
## 八、验证
|
||||
|
||||
1. 后端:`mvn clean compile -DskipTests -pl`(改动模块)——✅ 已通过(bfi-api / transport-api / blade-transport)
|
||||
2. 前端:`pnpm run typecheck`——⚠️ 仓库既有问题无法运行(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,TS7 移除 `./lib/tsc` 导出,与本特性无关);已用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js 语法通过
|
||||
3. 手动(用户执行):
|
||||
- 含附件单据(付款申请/开票申请/正式结算/收票登记)单条/批量同步 → 金蝶对应单据附件面板可见文件
|
||||
- 同步成功但附件失败(断网/超限模拟)→ 主单状态正常、列表「附件同步」列示未传/部分未传、重推自动补传
|
||||
- 重推已全部上传的单据 → A902 零调用(幂等跳过,日志「历史已成功 N 个」)
|
||||
- 收票登记:镜像发票文件经 A801-2 下载后出现在收票单附件面板
|
||||
- 关键日志链路:`调用BFI接口 附件上传开始/结束` → `金蝶附件上传成功` → `金蝶附件同步完成` → 各接入点「同步完成」日志
|
||||
|
||||
## 九、ADR 摘要(本次评审定案)
|
||||
|
||||
| # | 决策 | 备选与理由 |
|
||||
| --- | --- | --- |
|
||||
| ADR-1 | 后端联带自动传附件 | 前端两段式/独立按钮被否:状态机复杂、易漏传 |
|
||||
| ADR-2 | 附件独立状态+失败可补传,不自动重试 | 主单状态机不被污染;重推即补传零新增交互 |
|
||||
| ADR-3 | 幂等落独立同步表;**保留 attachmentsJson** | 附件业务事实(编辑/审批快照)与集成状态(幂等/path/原因)职责分离,url 幂等键是必要冗余而非数据冗余;推送期间单据锁定,无并发写 JSON 风险;去 JSON 化若要做属独立重构 |
|
||||
| ADR-4 | 本期 5 单据(开票申请 entityNumber 已确认 `sim_original_bill` 提入本期),客商预留配置 | entityNumber 是硬约束,猜错即传不上 |
|
||||
| ADR-5 | 同步接口超时 60s + 后端软限制跳过 | 大文件不拖死整批;不引入异步任务复杂度 |
|
||||
| ADR-6 | 收票含发票文件:A801-2 下载→A902 回传,key=path | path 变化(红冲重开)自动触发重传 |
|
||||
| ADR-7 | 附件上传在主单事务提交后执行(正式结算/开票申请拆 `doSyncKingdee` 事务体) | 附件是慢远程调用:放事务内会拉长持锁时间;放事务外失败只消化在附件链路,主单状态不被污染 |
|
||||
|
||||
## 十、实施记录(2026-09-24 as-built)
|
||||
|
||||
评审定案与设计的差异,均已按本节落地:
|
||||
|
||||
| # | 事项 | 设计原假设 | 实施定案 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 前端防连点模式 | 前置已落地、直接复用 | 未落地,本期不实施(后补) |
|
||||
| 2 | 前端超时 | 30s→60s 全量放宽 | 其余已 120s/300s 不动;仅 `invoiceReceipt.pushKingdee` 显式 120s(原全局 10s 默认) |
|
||||
| 3 | 同步流水落点 | 「现有同步流水」统一新增 action | 无统一流水表:新表即明细事实源;收票/开票申请有 actionType 流水表追加 `attachment_upload`,付款申请/正式结算不追加 |
|
||||
| 4 | 聚合态术语 | 正文与术语表两版冲突 | 定案:无附件/未上传/部分未传/已上传(CONTEXT.md 已落档) |
|
||||
| 5 | A801-2 客户端 | 需新增 downloadFile | 已有 `invoiceDownload`,直接复用 |
|
||||
| 6 | 应付/应收/收票推送返回 | 未明确内码获取方式 | `BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展 billId;幂等/存量经查询接口反查回填(`queryApBillExistId`/`queryArBillExistId`、A801-3/A303-4 查询行自带 id) |
|
||||
|
||||
关键产物:
|
||||
|
||||
- 后端:`IBfiClient.uploadAttachment`、`BfiAttachmentUploadVO`、`InMemoryMultipartFile`、`KingdeeAttachmentConstant`(biz_type/entityNumber/controlKey/20MB/聚合态)、`KingdeeAttachmentSync` 实体 + Mapper/Service、`KingdeeAttachmentSyncSupport`
|
||||
- 接线:PaymentApplicationServiceImpl、FormalSettlementServiceImpl、InvoiceReceiptServiceImpl、InvoiceApplicationServiceImpl、KingdeeApBillSyncSupport、KingdeeArBillSyncSupport、KingdeeReceivedInvoiceSyncSupport
|
||||
- 前端:4 option 列 + 4 页面 warning 提示 + invoiceReceipt.js 超时
|
||||
- SQL:`doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`(建表 + 3 加列,可重复执行)
|
||||
- 提交:tms-api `dee649d`(docs) → `79aaa9b`(feat) → `9f95c75`(log);tms-erp-web `aabe67e`
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| A902 | 星瀚「附件上传到指定单据」接口(注意与客商接口 A902-1/A902-2 是两套编号) |
|
||||
| A801-2 | 星瀚「文件下载」接口(票池发票影像) |
|
||||
| billPkId | 星瀚单据内码,保存响应 `BfiBillSaveResultItemVO.id`,A902 定位目标单据 |
|
||||
| entityNumber | 星瀚单据标识(如 ap_payapply) |
|
||||
| controlKey | 星瀚附件面板/附件字段标识,统一 attachmentpanel |
|
||||
| 附件幂等键 | 判定「同一附件已传过」的键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件) |
|
||||
| 镜像发票文件 | 票池镜像 attachmentsJson 保存的发票文件 {source,path,name},上传收票单时经 A801-2 取回回传 |
|
||||
| 附件同步聚合态 | 无附件/未上传/部分未传/已上传(列表展示用,运行时聚合) |
|
||||
| 重推补传 | 主单重复推送(含幂等跳过)时自动补传未成功附件,无新增交互 |
|
||||
@@ -0,0 +1,171 @@
|
||||
# 金蝶附件下载(A801-2 前端下载通道)设计文档
|
||||
|
||||
- 日期:2026-09-27
|
||||
- 范围:票池镜像发票文件面向前端的查看/下载通道(后端流式代理端点 + 前端三处入口改造);附件同步(A902)链路零改动
|
||||
- 背景:2026-09-24 金蝶附件同步设计核验时确认缺口——票池镜像发票文件的 attachmentsJson 为 `{source:"piaozone", path, name}`,其中 `path` 是金蝶侧文件地址(A801-1 的 downloadUrl,非 http 直链),前端 `invoiceAttachmentUrl`/`attachmentFileUrl` 只识别 `url/link/fileUrl`,因此正式结算认领发票行、付款申请发票附件行的「查看/下载」按钮对这类文件弹「暂无可查看/下载附件」;A801-2 后端能力已具备(`IBfiClient.invoiceDownload`),但仅用于附件同步链路(A801-2 下载 → A902 回传),无面向前端的下载端点。
|
||||
|
||||
## 一、目标
|
||||
|
||||
1. 前端可在票池弹窗、正式结算认领发票行、付款申请发票附件行三处查看/下载票池发票文件,文件经后端代理 A801-2 流式返回
|
||||
2. http 型附件(OSS 直链)的现有查看/下载行为完全不变,两类附件按字段特征自动分流
|
||||
3. 不提供「任意 path」下载能力,防止开放代理与越权读取金蝶文件
|
||||
|
||||
## 二、现状与缺口(核验结论)
|
||||
|
||||
| 入口 | 现状 | 缺口 |
|
||||
| --- | --- | --- |
|
||||
| 票池弹窗 `kingdee-invoice-pool-dialog` | 仅选择发票,无附件操作列 | 无查看/下载入口 |
|
||||
| 正式结算 `formal-settlement-editor.vue` 认领发票行 | 有 查看/下载 按钮,`invoiceAttachmentUrl` 只取 `url/link/fileUrl`(:2288-2305) | path 型附件取不到 url,按钮弹「暂无可查看/下载附件」 |
|
||||
| 付款申请 `payment-application-form.vue` 发票附件行 | 同上(:1385-1411) | 同上 |
|
||||
| 收票登记 `invoice-receipt-form.vue` | 登记附件(OSS 直链)正常 | 镜像发票文件入口由票池弹窗覆盖,本表单不改 |
|
||||
|
||||
行数据具备条件:票池弹窗选中行与认领行均 `...row` 展开(formal-settlement-editor.vue:2195-2216),自带 `invoiceNo` 与 `attachmentJson`,满足「按发票号定位」的入参要求(勘误说明见「实施记录」1)。
|
||||
|
||||
## 三、契约
|
||||
|
||||
### A801-2 文件下载(已有,零改动)
|
||||
|
||||
- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path": "<票池 downloadUrl>"}`,Header `access_token`(复用现有 getToken/token 缓存)
|
||||
- 返回文件流;客户端 `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` 已实现(status 校验、日志齐全),本期直接复用
|
||||
|
||||
### 契约核验(2026-09-27,源:《业财融合系统接口文档.xlsx》页签「文件下载」「查询全票池发票数据」「附件上传到指定单据接口」)
|
||||
|
||||
1. **A801-2 失败响应是 JSON 而非流**:`{"data":null,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。","status":false}`;成功响应文档未给出字段定义(即原始文件流)。后端代理必须先判 Content-Type 再写流——失败时解析 `message`/`errorCode` 透传给前端,不能把 JSON 错误体当文件下发。
|
||||
2. **path 形态三方一致**:A801-2 请求示例 `234e244b2c803400`、A801-1 票池响应 `downloadUrl:"234e244b2c803400"`(另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态;`originalFileName` 为 `serialNo.pdf`)、A902 返回 `data.path:"211e6ce5c9805c00"`——均为 16 位 hex 文件 id。镜像 path 可直接作为 A801-2 入参(与现状一致),且 A902 返回的 kingdee_path 与 A801-2 的 path 大概率同命名空间(见「待确认」1)。
|
||||
3. **entityNumber 映射获接口文档印证**:「附件上传到指定单据接口」页签内嵌映射表——付款申请单 `ap_payapply`、财务应付单 `ap_finapbill`、财务应收单 `ar_finarbill`、收票单 `ap_invoice`、开票申请单 `sim_original_bill`(controlKey 统一 `attachmentpanel`),与上期 `KingdeeAttachmentConstant` 完全一致,无需调整。
|
||||
4. A801-1 上游为票易通(queryParam 见 open.piaozone.com),`downloadUrl` 的有效期文档未说明;A902 页签未载明单文件大小上限与频率限制——「待确认」项维持。
|
||||
|
||||
### 新增 TMS 下载端点
|
||||
|
||||
`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>`
|
||||
|
||||
- 鉴权:BladeX 登录态(网关 + Secure 框架),与同控制器 `/invoice-pool` 查询一致,不额外加 `@PreAuth`
|
||||
- 租户隔离:`KingdeeInvoicePool extends TenantEntity`,`getById` 经 MP 租户插件自动过滤,跨租户 id 等同不存在
|
||||
- 成功响应:文件流。Header:
|
||||
- `Content-Type`:透传 BFI 响应头,缺失时兜底 `application/pdf`(票池发票文件均为 PDF)
|
||||
- `Content-Length`:BFI 响应头携带时透传
|
||||
- `Content-Disposition`:`attachment; filename="<ASCII兜底>"; filename*=UTF-8''<RFC5987编码>`,文件名取镜像 attachmentsJson 的 `name`(发票号.pdf)
|
||||
- 失败响应(均在写响应头**之前**失败,走全局异常处理返回 `R` JSON):
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 镜像未命中且 A801-1 实时回源未命中(含跨租户票号,租户隔离下等同不存在) | `ServiceException("该发票没有金蝶侧文件")` |
|
||||
| 镜像/实时行均无 downloadUrl(如 OFD-only 票) | 同上,`ServiceException("该发票没有金蝶侧文件")` |
|
||||
| BFI 返回非 200,或响应体为 JSON 错误(如 `FILE_IS_NOT_EXIST`) | 解析 BFI `message`/`errorCode` 抛 `ServiceException`(如「调用BFI文件下载失败:附件不存在。」),错误信息透传前端 |
|
||||
| 传输中断 | 已写头后无法回退,流中断即连接断开,前端 reject |
|
||||
|
||||
## 四、后端设计
|
||||
|
||||
接线(均在既有文件内新增,无新文件):
|
||||
|
||||
- `InvoiceReceiptController`:新增 `downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response)`,与 `/invoice-pool` 同域同风格(`@GetMapping` + `@ApiOperationSupport` + `@Operation`)
|
||||
- `IInvoiceReceiptService` / `InvoiceReceiptServiceImpl`:新增 `downloadPoolAttachment(String invoiceNo, HttpServletResponse response)`
|
||||
|
||||
流程:
|
||||
|
||||
```
|
||||
取镜像(自动租户过滤)→ 解析 attachmentsJson 取 path/name
|
||||
→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前
|
||||
→ 校验 status==200 且 Content-Type 非 JSON(防御 BFI 以 200 返回错误体)
|
||||
→ 写响应头(Content-Type / Content-Length / Content-Disposition)
|
||||
→ 8KB 缓冲流式拷贝 feign Response → ServletOutputStream,flush,finally 关闭 Response
|
||||
```
|
||||
|
||||
设计要点:
|
||||
|
||||
1. **path 一律服务端解析,接口不收 path 参数(ADR-1)**:按发票号定位,解析顺序「镜像快照优先 → A801-1 实时回源兜底」——镜像快照与上传链路(A902 镜像文件取回)同源同口径(同取 `blade_kingdee_invoice_pool.attachmentsJson`);但票池弹窗行是 A801-1 实时行、未必已入镜像,故下载必须有实时回源兜底(上传只发生在已绑定镜像的收票单上,无此场景)。镜像快照过期(红冲重开)时报「附件不存在。」不自动刷新,与上传取快照口径一致,刷新机制见「待确认」2
|
||||
2. **先校验后写流**:所有可预知错误发生在写头之前,保证前端拿到的是可识别的 JSON 错误而非损坏文件(ADR-3)。A801-2 失败时返回 JSON(`{"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}`,HTTP 状态文档未标注,按 200 + `status:false` 防御),解析 `message`/`errorCode` 透传给前端;成功时按 Content-Type 判定为文件流再写响应
|
||||
3. **全程流式**:不落盘、不整文件进堆,与同步链路的 20MB `readCapped` 无关——下载通道**不设 20MB 上限**(ADR-4)
|
||||
4. **复用 BFI 配置**:token 缓存与 Feign 超时(连 10s / 读 60s,`BfiFeignClientConfig`)不动;无事务、不落附件同步表、不记操作流水
|
||||
5. **日志**:BFI 侧沿用「调用BFI接口 进项发票下载开始/结束」;业务侧补一条 info(操作人、发票号、字节数),便于审计
|
||||
6. 文件名编码:RFC 5987 `filename*` + ASCII 兜底,中文发票号不乱码
|
||||
|
||||
## 五、前端设计
|
||||
|
||||
### API 与公共工具
|
||||
|
||||
- `src/api/payment/invoiceReceipt.js` 新增:
|
||||
|
||||
```js
|
||||
// 金蝶票池发票文件下载(后端代理 A801-2),耗时较普通接口长,放宽到 120s
|
||||
export const downloadInvoicePoolAttachment = invoiceNo =>
|
||||
request({ url: `${baseUrl}/invoice-pool/attachment/download`, method: 'get', params: { invoiceNo }, responseType: 'blob', timeout: 120000 });
|
||||
```
|
||||
|
||||
- 新增公共工具 `src/utils/kingdee-attachment.js`(三入口共用,避免复制三份):
|
||||
- `isKingdeePathAttachment(attachment)`:`source === 'piaozone'`,或「有 `path` 且非 http(s) 开头」——判定为金蝶侧文件
|
||||
- `downloadKingdeeAttachment(row)`:金蝶型 → 调下载 API;若响应 blob 的 `type` 含 `application/json` 则读取文本解析出 msg 后 `message.error`(兼容全局异常处理器以 200 返回 R 的情况);否则 `downloadFile(blob, name)`;URL 型 → 回退既有 `downloadFileByUrl`
|
||||
- `viewKingdeeAttachment(row)`:金蝶型 → 拉取 blob 后 `URL.createObjectURL` 新窗口打开(PDF 可预览;OFD 等浏览器不支持的格式提示改用下载);URL 型 → 维持 `window.open(url)`
|
||||
- 两个动作均带行级 loading 守卫(单行按钮级即可,沿用「防连点全局模式本期不实施」的口径)
|
||||
|
||||
### 入口改造(三处)
|
||||
|
||||
| 文件 | 改造点 |
|
||||
| --- | --- |
|
||||
| `src/components/kingdee-invoice-pool-dialog/main.vue` | 表格新增「附件」操作列:查看 / 下载(行自带 invoiceNo + attachmentJson) |
|
||||
| `src/views/settlement/components/formal-settlement-editor.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:2296-2305)增加金蝶型分支,走公共工具;URL 型行为不变 |
|
||||
| `src/views/payment/payment-application-form.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:1393-1411)同上 |
|
||||
|
||||
收票登记表单不改(镜像文件入口由票池弹窗覆盖)。
|
||||
|
||||
## 六、不做的事
|
||||
|
||||
- **已上传金蝶附件(`blade_kingdee_attachment_sync.kingdee_path`)的回源下载**:A801-2 对 A902 返回 path(如 `211e6ce5c9805c00`)的兼容性未验证,待金蝶确认后二期评估
|
||||
- 批量发票文件打包 zip 下载
|
||||
- 后端转存 OSS 再发直链的方案(ADR-2 否决,理由见下)
|
||||
- 附件同步(A902)链路与 `KingdeeAttachmentSyncSupport` 的任何改动
|
||||
|
||||
## 七、待确认(金蝶侧)
|
||||
|
||||
1. A801-2 是否可下载 A902 返回的 `data.path`(决定二期「从金蝶恢复已上传附件」可行性)。契约核验补充依据:A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path 参数示例(`234e244b2c803400`)同为 16 位 hex 文件 id,疑似同一文件命名空间,大概率兼容,需实测确认
|
||||
2. 票池 `downloadUrl` 的有效期与失效表现:镜像落库后 path 是否会过期、失效时 A801-2 返回什么(已知失效类错误如 `FILE_IS_NOT_EXIST`「附件不存在。」);接口文档未载明有效期,上游为票易通(queryParam 见 open.piaozone.com),若会过期,需明确经 A801-1 刷新镜像的机制(影响下载失败时的用户提示与补救路径)
|
||||
3. A801-2 频率限制(本场景为用户点击型、低频,预期无压力,确认即可);A902 页签亦未载明单文件大小上限与频率限制,上期 20MB 软限制待一并确认
|
||||
|
||||
## 八、验证
|
||||
|
||||
1. 后端:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am`
|
||||
2. 前端:`pnpm run typecheck` 因仓库既有问题不可用(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,与上期同因),沿用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js
|
||||
3. 手动(用户执行):
|
||||
- 票池弹窗行 查看/下载 → 得到 `发票号.pdf`,内容可打开
|
||||
- 正式结算认领发票行、付款申请发票附件行:金蝶型附件 查看/下载 成功;OSS 型附件行为与改造前完全一致
|
||||
- 镜像缺失 path / 发票未同步票池 → 友好提示,不产生损坏文件
|
||||
- 不存在的发票号 → 「该发票没有金蝶侧文件」(镜像与 A801-1 回源均未命中;跨租户票号在租户隔离下等同不存在)
|
||||
- BFI 停机/超时模拟 → 前端 JSON 错误识别并提示,浏览器不落损坏文件
|
||||
- path 失效模拟(BFI 返回 `FILE_IS_NOT_EXIST`)→ 前端提示透传的「附件不存在。」,不落损坏文件
|
||||
- 日志链路:`调用BFI接口 进项发票下载` → 业务侧下载 info
|
||||
|
||||
## 九、ADR 摘要
|
||||
|
||||
| # | 决策 | 备选与理由 |
|
||||
| --- | --- | --- |
|
||||
| ADR-1 | 按发票号定位,path 服务端解析(镜像优先 → A801-1 实时兜底) | 前端传 path 被否:等同开放代理,可遍历下载金蝶任意文件;只信镜像不够——票池弹窗是 A801-1 实时行、未必已入镜像;发票号在镜像表同租户唯一(A801-1 upsert 匹配键),天然带租户与数据边界 |
|
||||
| ADR-2 | 后端流式代理,不转存 OSS | 转存需处理双存储一致性、清理策略与额外延时;票池文件为点击型低频小文件(PDF),代理足够;若日后高频/大文件再评估 |
|
||||
| ADR-3 | 先校验后写流,错误返回 JSON | 先写头后失败会导致前端拿到损坏文件且无法提示;前端按响应 Content-Type 是否为 JSON 统一识别成败,不依赖 HTTP 状态码细节 |
|
||||
| ADR-4 | 下载通道不设 20MB 上限 | 20MB 是 A902 上传软限制(金蝶契约),A801-2 下载无此约束,流式透传不占堆内存 |
|
||||
| ADR-5 | 查看走 blob objectURL 新窗口,作为增强项 | 后端不区分 inline/attachment,减少契约面;OFD 等不可预览格式由前端降级为下载提示 |
|
||||
|
||||
## 十、实施记录(2026-09-27 as-built)
|
||||
|
||||
实施中发现的现状与设计的两处偏差,已按实际落地(勘误同上期 as-built 风格):
|
||||
|
||||
| # | 事项 | 设计原假设 | 实施定案 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 下载定位键 | 按票池镜像 id(`GET /invoice-pool/{poolId}/attachment/download`) | **按发票号**(`GET /invoice-pool/attachment/download?invoiceNo=`)。原因:票池弹窗行是 A801-1 实时行、`id` 恒空(`toPoolViewVO` 注释即注明);正式结算/付款详情行是关系行(`FormalSettlementInvoice`/`PaymentApplicationInvoice` 的 id 为关系行 id 而非镜像 id)。三处入口统一仅可靠携带 `invoiceNo`;票号在镜像表同租户唯一(A801-1 upsert 匹配键),按票号取**实时镜像**还自动跟随 path 变化(红冲重开)。定位顺序:镜像优先(0 远程调用)→ 镜像未落库/无文件时实时回源 A801-1 按票号取 `downloadUrl`(远程模糊匹配命中行做全等防御) |
|
||||
| 2 | 失败返回方式 | 走全局异常处理返回 R JSON | 写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)在服务内捕获后经 `WebUtil.renderJson(response, R.fail(msg))` 直接写 HTTP 200 + JSON。原因:blade-transport 无本地异常处理器,全局处理器行为在 jar 内不可控(可能非 200,axios 拦截器对 blob 错误体无法解析出 msg);`WebUtil.renderJson` 为本仓库既有惯例(运单批量导入校验同款)。前端 `kingdee-attachment.js` 统一按响应 Content-Type 含 `application/json` 识别失败并弹出 msg |
|
||||
|
||||
实施产物:
|
||||
|
||||
- 后端:`InvoiceReceiptController.downloadPoolAttachment`(`/invoice-receipt/invoice-pool/attachment/download`,GET,继承类级 `@PreAuth(menu="invoice_receipt")`)、`IInvoiceReceiptService.downloadPoolAttachment`、`InvoiceReceiptServiceImpl` 实现(`resolvePoolAttachment` 定位 + `doDownloadPoolAttachment` A801-2 流式透传 + `readBfiJsonMessage` 错误体解析,`bfiResponse.close()` finally 兜底;IOException(客户端断开)仅记日志)
|
||||
- 前端:`api/payment/invoiceReceipt.js` 新增 `downloadInvoicePoolAttachment`(blob + 120s);新增 `utils/kingdee-attachment.js`(`isKingdeePathAttachment` 判定 + 下载/预览 + JSON 错误识别 + 静默标记防双 toast);三处入口——票池弹窗新增「附件」列(行级 `attachmentBusy` 守卫)、正式结算编辑器与付款申请表单的 查看/下载 按钮按附件类型分流(`kingdeeAttachmentBusy` 守卫,OSS 直链行为不变)
|
||||
- 验证:后端 `mvn clean compile -DskipTests -pl blade-service/blade-transport -am` ✅;前端 @vue/compiler-sfc 解析 + `node --check` 全部改动文件 ✅(`pnpm run typecheck` 因仓库既有 vue-tsc/TS7 不兼容问题不可用,与上期同因)
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| A801-2 | 星瀚「文件下载」接口,按 path 取回文件流(本期对前端暴露的下载通道) |
|
||||
| 票池镜像 | `blade_kingdee_invoice_pool`,A801-1 全票池发票的本地镜像,attachmentsJson 存 `{source:"piaozone", path, name}` |
|
||||
| path 型附件 | 金蝶侧文件:attachmentsJson 项有 `path` 无 http url,只能经 A801-2 取回 |
|
||||
| URL 型附件 | OSS 直链附件:attachmentsJson 项有 `url/link/fileUrl`,前端可直连下载(现有行为) |
|
||||
| billPkId | 星瀚单据内码(上期 A902 使用,本特性不涉及,仅口径区分) |
|
||||
| 开放代理 | 接口接受任意目标地址参数并代为取回内容的安全缺陷,本设计以 id 定位规避 |
|
||||
@@ -0,0 +1,202 @@
|
||||
# TMS ↔ 金蝶 上传/下载链路梳理
|
||||
|
||||
- 日期:2026-09-27
|
||||
- 依据:代码真实实现(blade-third-party-api/blade-bfi-api、blade-service/blade-transport)+ 《2026-09-24-kingdee-attachment-sync-design.md》《2026-09-27-kingdee-attachment-download-design.md》
|
||||
- 回答三个问题:**系统需要从金蝶下载哪些东西**、**上传和下载的逻辑是什么**、**上传与下载能否对应上(闭环)**
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
1. **文件级下载目前只有一种:票池发票文件(发票影像 PDF)**。判断正确——全代码库中唯一返回文件流的金蝶接口是 A801-2「文件下载」(`IBfiClient.invoiceDownload`),它的消费方只有两处,都围绕票池发票文件:① 附件同步链路内「下载回传」收票单;② 面向前端的票池发票文件下载端点(2026-09-27 新增,当前未提交)。
|
||||
2. 除文件外,系统还从金蝶「下载」**数据**(JSON 查询结果):票池发票元数据(A801-1)、收票单结果(A801-3)、付款申请/付款处理结果(A103-2/A103-4)、应付/应收单结果(A102-4/A302-4)、开票申请单与开票结果(A303-4/A303-5)、客商(A902-1)、汇率(A801-4)。这些用于幂等预检、星瀚内码反查、状态/结果定时回写。
|
||||
3. **上传与下载基本能对应上,但有三处缺口**:① 已上传金蝶的附件(`blade_kingdee_attachment_sync.kingdee_path`)没有回源下载通道(A801-2 是否兼容 A902 返回的 path 未验证,二期待金蝶确认);② 收款认领链路(A301-1/2/3)客户端已实现、业务零接线;③ 客商资质附件上传(A902)二期预留,entityNumber 未确认——客商附件目前既无上传也无下载。
|
||||
4. 每一个「推送/保存」类接口都有对应的「查询」类接口做幂等预检或结果回写,数据面上传下载成对;文件面只有「下载发票→上传回传」一条闭环。
|
||||
|
||||
---
|
||||
|
||||
## 二、接口总清单与业务接线状态
|
||||
|
||||
`IBfiClient`(blade-bfi-api)共 25 个接口,按业务接线情况分三类:
|
||||
|
||||
### 2.1 业务已接线(上传/推送类)
|
||||
|
||||
| 编号 | 接口 | IBfiService 方法 | 业务调用点 |
|
||||
| --- | --- | --- | --- |
|
||||
| A103-1 | 应付下推付款申请 | `pushPayApply` | PaymentApplicationServiceImpl:1045 |
|
||||
| A103-3 | 预付款申请保存 | `savePrePayApply` | PaymentApplicationServiceImpl:949 |
|
||||
| A102-2 | 财务应付单保存 | `saveApBill` | KingdeeApBillSyncSupport:318 |
|
||||
| A302-2 | 财务应收单保存 | `saveArBill` | KingdeeArBillSyncSupport:258 |
|
||||
| A303-2 | 收票单新增(批量) | `saveReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:218 |
|
||||
| A302-6 | 收票指定应付 | `invoiceAssign` | KingdeeReceivedInvoiceSyncSupport:288 |
|
||||
| A303-1 | 开票申请单新增 | `saveInvoiceApply` | InvoiceApplicationServiceImpl:702 |
|
||||
| A902-2 | 客户保存 | `saveCustomer` | CustomerArchiveServiceImpl:527 |
|
||||
| **A902** | **附件上传到指定单据** | `uploadAttachment` | KingdeeAttachmentSyncSupport:164(唯一入口) |
|
||||
|
||||
### 2.2 业务已接线(下载/查询类)
|
||||
|
||||
| 编号 | 接口 | IBfiService 方法 | 业务调用点 | 用途 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A801-1 | 查询全票池发票数据 | `queryInvoicePool` | InvoiceReceiptServiceImpl:285、1110 | 选票弹窗实时查询;保存收票登记落镜像;下载回源兜底 |
|
||||
| A801-3 | 收票单查询 | `queryReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:115 | 推送幂等预检;定时回写 |
|
||||
| **A801-2** | **文件下载(发票文件)** | `downloadInvoice` | InvoiceReceiptServiceImpl:1248、KingdeeAttachmentSyncSupport:367 | **见第三节** |
|
||||
| A102-4 | 财务应付单查询 | `queryApBill` | KingdeeApBillSyncSupport:155、182 | 幂等预检;正式结算内码反查 |
|
||||
| A302-4 | 财务应收单查询 | `queryArBill` | KingdeeArBillSyncSupport:106、133 | 幂等预检;应收结果回写、开票前置校验、内码反查 |
|
||||
| A103-2 | 付款申请单查询 | `queryPayApply` | PaymentApplicationServiceImpl:743 | 付款结果定时回写;单条状态回查 |
|
||||
| A103-4 | 付款处理查询 | `queryPayBill` | PaymentApplicationServiceImpl:833 | 付款处理结果勾稽回写 |
|
||||
| A303-4 | 开票申请单查询 | `queryInvoiceApply` | InvoiceApplicationServiceImpl:581 | 推送幂等预检;附件内码反查 |
|
||||
| A303-5 | 开票申请发票查询(OpenApi) | `queryInvoiceApplyInvoice` | InvoiceApplicationServiceImpl:857 | 开票结果回写(发票号落库) |
|
||||
| A902-1 | 客户查询 | `queryCustomer` | CustomerArchiveServiceImpl:554、821;BfiCustomerController:70 | 推送前置幂等复查;定时同步复查;客商列表穿透查询 |
|
||||
| A801-4 | 汇率查询 | `queryExchangeRate` | BfiExchangeRateController:63(GET /bfi/exchange-rate) | 前端汇率展示 |
|
||||
|
||||
### 2.3 客户端已实现、业务零调用(仅测试覆盖)
|
||||
|
||||
| 编号 | 接口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| A301-1 | 收款单查询(`queryRecBill`) | 收款认领链路未启用 |
|
||||
| A301-2 | 收款认领公告查询(`queryClaimAnnounce`) | 同上 |
|
||||
| A301-3 | 收款认领结果推送(`pushClaimResult`) | 同上(上传方向) |
|
||||
| A100-2/3 | 令牌校验/撤回 | 基础设施预留,token 管理由 `BfiRequestInterceptor`/缓存承担 |
|
||||
|
||||
> 结论:**收款认领(收款单从金蝶下载 → TMS 认领 → 结果回推)是设计上预留、尚未接线的下载场景**,是目前除发票文件外唯一「未来可能需要从金蝶下载」的业务,与《业财融合方案》中收款认领规划对应。
|
||||
|
||||
---
|
||||
|
||||
## 三、文件下载链路(金蝶 → TMS)——「下载什么」的完整答案
|
||||
|
||||
### 3.1 唯一被下载的文件:票池发票文件
|
||||
|
||||
文件从哪来:A801-1 票池查询响应的 `downloadUrl`(16 位 hex 文件 id,另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态),保存收票登记落镜像时序列化为 `blade_kingdee_invoice_pool.attachmentsJson`:`{"source":"piaozone","path":"<downloadUrl>","name":"<发票号>.pdf"}`(InvoiceReceiptServiceImpl `buildAttachmentJson`:1206-1215)。
|
||||
|
||||
文件怎么取:A801-2 `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path":"..."}`,返回文件流。BFI 层实现 `BfiServiceImpl.downloadInvoice`:532-555(校验 path 非空、HTTP 200、日志齐全),失败时金蝶返回 JSON 错误体(如 `FILE_IS_NOT_EXIST`「附件不存在。」)。
|
||||
|
||||
### 3.2 用途一:附件同步链路内下载回传(后端自动,无前端交互)
|
||||
|
||||
`KingdeeAttachmentSyncSupport.fetchContent`:365-385:当附件项 `kingdeePathSource=true`(镜像发票文件)时,调 `bfiService.downloadInvoice(path)` → `readCapped` 流式读取(64KB 缓冲,**20MB 封顶**,超限中断并记失败原因)→ 内存字节数组 → A902 上传至收票单附件面板。
|
||||
|
||||
特点:同步链路内的下载**必须**经 20MB 软限制(A902 上传契约约束),下载的文件即传即走、不落盘不存 OSS。
|
||||
|
||||
### 3.3 用途二:前端下载通道(用户点击,2026-09-27 新增,当前未提交)
|
||||
|
||||
端点:`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>`(InvoiceReceiptController:90-96,类级 `@PreAuth(menu="invoice_receipt")`)。
|
||||
|
||||
实现(InvoiceReceiptServiceImpl `downloadPoolAttachment`:1219-1312):
|
||||
|
||||
```
|
||||
按发票号定位 path(resolvePoolAttachment)
|
||||
① 本地镜像优先:blade_kingdee_invoice_pool 按票号查(MP 租户插件自动隔离),解析 attachmentsJson 取 path/name
|
||||
② 镜像未落库或无文件 → A801-1 实时回源(queryPoolByInvoiceNo,模糊匹配结果做票号全等防御)
|
||||
→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前
|
||||
→ 校验:HTTP 200(BFI 层)+ Content-Type 非 application/json(防 JSON 错误体当文件下发)
|
||||
→ 写响应头(Content-Type 缺省兜底 application/pdf / Content-Length 透传 / Content-Disposition RFC5987 文件名)
|
||||
→ 8KB 缓冲流式透传 feign Response → ServletOutputStream,不落盘不进堆,finally 关闭 Response
|
||||
```
|
||||
|
||||
错误处理:写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)经 `WebUtil.renderJson(response, R.fail(msg))` 写 HTTP 200 JSON,前端按响应 Content-Type 是否含 `application/json` 统一识别;传输中断(IOException,客户端断开)仅记日志。**此通道不设 20MB 上限**(20MB 是 A902 上传契约,A801-2 下载无此约束)。
|
||||
|
||||
前端三处入口(票池弹窗附件列、正式结算认领发票行、付款申请发票附件行)经 `kingdee-attachment.js` 分流:`source === 'piaozone'` 或「有 path 且非 http(s) 开头」判为金蝶型走该端点;OSS 直链附件维持现有 `downloadFileByUrl` 行为不变。
|
||||
|
||||
### 3.4 数据下载(查询回写)的触发机制
|
||||
|
||||
| 定时任务(PaymentKingdeeResultTask / CustomerKingdeeSyncTask) | cron 默认 | 拉取内容 | 回写目标 |
|
||||
| --- | --- | --- | --- |
|
||||
| payResult | 每 10 分钟 | A103-2 付款申请单状态 + A103-4 付款处理结果 | 付款申请 kingdee_status |
|
||||
| receivedInvoiceResult | 每 10 分钟 | A801-3 收票单结果 | 收票登记 kingdee_push_status、内码 |
|
||||
| formalSettlement 应收回写(`syncKingdeeArResult`:631,随 payResult 任务调度) | 同上 | A302-4 应收单结果 | 正式结算应收面板状态 |
|
||||
| invoiceApplication 开票结果(`syncKingdeeInvoiceResult`:803) | 同上 | A303-4 申请单 + A303-5 发票明细 | 开票申请状态 + 发票号落库 |
|
||||
| customer(CustomerKingdeeSyncTask) | 每天凌晨 2 点 | A902-1 客商(按信用代码批量复查) | 客商档案协同状态 |
|
||||
|
||||
另有手动触发点:各单据「同步金蝶结果」按钮(如 InvoiceReceiptController `/sync-kingdee-result`)、推送前的幂等预检(A102-4/A302-4/A801-3/A303-4/A103-2)、附件内码反查、票池选票弹窗实时查询(A801-1,游标分页 + 可用性过滤)。
|
||||
|
||||
---
|
||||
|
||||
## 四、文件上传链路(TMS → 金蝶)——A902 附件同步
|
||||
|
||||
### 4.1 唯一上传通道
|
||||
|
||||
`KingdeeAttachmentSyncSupport.syncAttachments`:125-189,经 `BfiServiceImpl.uploadAttachment`:558-583 调 A902 `POST /ierp/kapi/v2/frame/attachment/uploadFile`(multipart,`file` + `attachmentUploadFileArgs` JSON:entityNumber/billPkId/controlKey),返回 `data.path` 落 `blade_kingdee_attachment_sync.kingdee_path`。
|
||||
|
||||
核心机制:
|
||||
|
||||
- **幂等**:按 `(biz_type, biz_id, attachment_key)` 唯一键;同步前载入已成功集合,命中即跳过(重推 A902 零调用);失败记录重试成功时覆盖更新
|
||||
- **幂等键**:用户附件 = 附件 url;发票文件 = A801-2 的 path(path 变化即红冲重开,自动视为新文件重传)
|
||||
- **限制**:单文件 20MB 软限制(下载前按声明大小拦截 + 下载后按实际字节校验)
|
||||
- **隔离**:逐附件独立 try/catch,失败记 `fail_reason` 继续下一个;附件失败不回滚主单、不阻塞同批其他单据
|
||||
- **补传**:不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件分支)
|
||||
|
||||
### 4.2 五类单据接入点(均已落地,主单事务提交后执行)
|
||||
|
||||
| 单据 | biz_type → entityNumber | 接入点(代码位置) | 附件来源 | billPkId 缺失时的反查 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 付款申请 | `pay_application` → `ap_payapply` | PaymentApplicationServiceImpl.syncKingdee:387 → doSyncKingdee 事务提交后 :403 → syncAttachmentsAfterPush:414 | 单据 attachmentsJson(用户附件) | 无(本地 kingdeeBillId 缺失即跳过待重推) |
|
||||
| 正式结算-应付 | `formal_settlement_ap` → `ap_finapbill` | FormalSettlementServiceImpl.syncKingdee:510 → syncAttachmentsAfterPush:525 | 结算单 attachmentsJson | A102-4 按防重键反查回填(`resolveKingdeeBillId`) |
|
||||
| 正式结算-应收 | `formal_settlement_ar` → `ar_finarbill` | 同上(按 settlementType 分流 :547-548) | 同上 | A302-4 反查回填 |
|
||||
| 收票登记 | `invoice_receipt` → `ap_invoice` | InvoiceReceiptServiceImpl.pushOneReceivedInvoice:720,A303-2 落 pushed 后、A302-6 指定应付前 :786 → syncAttachmentsAfterPush:809 | 登记附件(用户附件)+ 镜像发票文件(**A801-2 下载**) | A801-3 预检行自带 id 回填 :752-754 |
|
||||
| 开票申请 | `invoice_application` → `sim_original_bill` | InvoiceApplicationServiceImpl.syncKingdee:497 → syncAttachmentsAfterPush:511 | 单据 attachmentsJson | A303-4 勾稽回填带回 |
|
||||
|
||||
流水落点:收票登记(`InvoiceReceiptRecord`)与开票申请(`InvoiceApplicationRecord`)有 actionType 流水表,追加 `attachment_upload` 记录;付款申请/正式结算无流水表不追加。列表聚合态(无附件/未上传/部分未传/已上传)由 `successCountByBizId`(一次 GROUP BY)+ `aggregateStatus` 运行时聚合。
|
||||
|
||||
### 4.3 数据上传(单据推送)不在本档展开
|
||||
|
||||
付款申请(A103-1/A103-3)、财务应付/应收单(A102-2/A302-2)、收票单+指定应付(A303-2/A302-6)、开票申请(A303-1)、客商(A902-2)的报文组装与状态机,见各 Support 类与《业财融合方案》主文档。
|
||||
|
||||
---
|
||||
|
||||
## 五、上传 ↔ 下载对应关系(能否对上)
|
||||
|
||||
### 5.1 已闭环的对应
|
||||
|
||||
| 对应项 | 上传侧 | 下载侧 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| **票池发票文件** | A801-2 下载(20MB 封顶)→ A902 回传收票单附件面板,key=path | A801-2 前端下载端点(不设上限),镜像优先 + A801-1 回源 | ✅ 完整闭环:同一份文件既能自动回传金蝶,又能给前端人工查看/下载 |
|
||||
| **用户附件(OSS 直链)** | http(s) 下载(60s 超时、20MB 封顶)→ A902 上传 5 类单据 | 不经金蝶:前端直连 OSS(`downloadFileByUrl` 现有行为) | ✅ 各自闭环,两类附件按字段特征分流互不干扰 |
|
||||
| **单据数据面** | A103-1/3、A102-2、A302-2、A303-2/6、A303-1、A902-2 | A103-2/4、A102-4、A302-4、A801-3、A303-4/5、A902-1(幂等预检 + 内码反查 + 结果回写) | ✅ 每个推送接口都有查询接口配套,成对出现 |
|
||||
| **票池元数据** | (消费方)收票登记落镜像、A303-2 分录取数 | A801-1 实时查询(选票弹窗 + 回源) | ✅ 闭环 |
|
||||
|
||||
### 5.2 对不上的缺口(单向或未启用)
|
||||
|
||||
| # | 缺口 | 现状 | 影响 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **已上传附件的金蝶侧回源下载** | A902 上传成功后 `kingdee_path` 已落 `blade_kingdee_attachment_sync`,但没有任何代码用它发起 A801-2 下载 | TMS 侧附件若 OSS 丢失/清理,无法从金蝶恢复。A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path(`234e244b2c803400`)同为 16 位 hex,疑似同命名空间,但兼容性**未验证**——2026-09-27 设计「不做的事」第 1 条明确列为二期待确认项 |
|
||||
| 2 | **收款认领链路** | A301-1 收款单查询、A301-2 认领公告查询、A301-3 结果推送在 `IBfiClient`/`BfiServiceImpl` 已完整实现,业务层零调用 | 金蝶收款数据下载 → TMS 认领 → 结果回推整条链路待启用;启用前 A301 系列不产生任何下载 |
|
||||
| 3 | **客商资质附件** | `KingdeeAttachmentConstant.ENTITY_NUMBERS` 仅预留注释(CustomerArchiveServiceImpl 侧无任何附件上传接线);客商的 entityNumber/controlKey 待金蝶确认 | 客商附件目前既不上传也不下载,二期接入时补一个 biz_type 条目即可复用现有架构 |
|
||||
| 4 | 票池文件时效性 | 镜像 `attachmentsJson` 落库后 path 是否会过期未知;下载时镜像快照过期(红冲重开)报「附件不存在」**不自动刷新**,与上传取快照口径一致 | 需金蝶确认 `downloadUrl` 有效期与失效表现,决定是否要补 A801-1 定时刷新镜像机制 |
|
||||
| 5 | 附件面板反向查询 | 金蝶无「查询单据附件列表」接口接入 | 附件同步状态只能以本地同步表为准,无法与金蝶侧附件面板对账 |
|
||||
|
||||
---
|
||||
|
||||
## 六、验证核对表(文档 vs 代码一致性)
|
||||
|
||||
| 设计文档断言 | 代码核实结果 |
|
||||
| --- | --- |
|
||||
| A902 五类单据接入、entityNumber 映射 | ✅ `KingdeeAttachmentConstant.ENTITY_NUMBERS` 五条映射与文档完全一致,未配置类型抛 ServiceException |
|
||||
| A801-2 复用 `invoiceDownload`,附件链路无新增接口 | ✅ `KingdeeAttachmentSyncSupport.fetchContent` 直接调 `bfiService.downloadInvoice` |
|
||||
| 附件上传在主单事务提交后执行(ADR-7) | ✅ 付款申请经 `syncKingdee` 包装层(doSyncKingdee 事务外 :403);正式结算/开票申请经 `SpringUtil.getBean(...).doSyncKingdee` 自代理(:512/:499);收票在推送编排内但独立 try/catch(:786) |
|
||||
| 幂等键 url/path、失败覆盖更新、20MB 双重校验 | ✅ syncAttachments:144-177(跳过/超限拦截/覆盖更新),readCapped:388-403 |
|
||||
| 下载按发票号定位、镜像优先 + 实时回源、先校验后写流 | ✅ resolvePoolAttachment:1292-1312、doDownloadPoolAttachment:1242-1285,JSON 错误体解析 readBfiJsonMessage:1315-1332 |
|
||||
| 下载不设 20MB 上限、8KB 流式透传 | ✅ DOWNLOAD_BUFFER_SIZE 缓冲拷贝,无 readCapped 调用 |
|
||||
| 失败经 `WebUtil.renderJson` 返回 200 JSON(勘误 #2) | ✅ downloadPoolAttachment:1222-1235 |
|
||||
| 收票/开票申请追加 `attachment_upload` 流水,付款/结算不追加 | ✅ InvoiceReceiptServiceImpl:823、InvoiceApplicationServiceImpl:522;付款/结算接入点无 record 调用 |
|
||||
|
||||
---
|
||||
|
||||
## 七、顺带发现的风险点(与本次梳理主题相关,建议确认)
|
||||
|
||||
1. **客商定时同步被硬编码过滤到单一客商**:`CustomerArchiveServiceImpl.syncKingdeeCustomerData`:625-628 的查询条件含 `.eq(CustomerArchive::getCustomerCode, "KS000067")`,即每天凌晨 2 点的客商同步任务只会处理这一个客商编码,疑似联调/灰度遗留。上线前需移除,否则其余客商的定时推送与状态复查全部不生效。
|
||||
2. **2026-09-27 下载功能代码未提交**:`InvoiceReceiptController`、`IInvoiceReceiptService`、`InvoiceReceiptServiceImpl` 的下载端点改动仍在工作区(git status modified),设计文档已写 as-built,注意随下次提交入库。
|
||||
3. 镜像定位 `resolvePoolAttachment`:1293-1294 按票号 selectList 后取 `mirrors.get(0)`:依赖「同租户票号唯一」(A801-1 upsert 匹配键保证),若上游契约变化出现重复票号将静默取第一条,可考虑加 `last("limit 1")` 或唯一索引兜底(当前表无票号唯一索引约束见 `blade_kingdee_invoice_pool_a8011_20260920.sql`)。
|
||||
|
||||
---
|
||||
|
||||
## 八、术语与代码索引
|
||||
|
||||
| 术语 | 代码落点 |
|
||||
| --- | --- |
|
||||
| A902 附件上传 | `IBfiClient.uploadAttachment`(blade-bfi-api feign)/ `BfiServiceImpl.uploadAttachment` |
|
||||
| A801-2 文件下载 | `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` |
|
||||
| 附件同步主流程 | `org.springblade.transport.support.KingdeeAttachmentSyncSupport` |
|
||||
| 附件同步表 | `blade_kingdee_attachment_sync`(实体 `KingdeeAttachmentSync`,建表 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`) |
|
||||
| 票池镜像 | `blade_kingdee_invoice_pool`(`KingdeeInvoicePool`,`InvoiceReceiptServiceImpl` 内 upsert) |
|
||||
| 前端下载端点 | `InvoiceReceiptController.downloadPoolAttachment`(GET /invoice-receipt/invoice-pool/attachment/download) |
|
||||
| entityNumber 映射 | `org.springblade.transport.constant.KingdeeAttachmentConstant` |
|
||||
| 定时回写任务 | `org.springblade.transport.task.PaymentKingdeeResultTask`、`CustomerKingdeeSyncTask` |
|
||||
@@ -0,0 +1,75 @@
|
||||
-- ① 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`(256)),
|
||||
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;
|
||||
Reference in New Issue
Block a user