feat: 付款特批结果附件落库并经C103-5下载联带上传金蝶

paymentLegal/result 回调 fileUrl(合规附件ID)落库 hcs_spec_attach_fd_id;金蝶附件联带上传新增合规附件来源(C103-5 按 fdId 下载,幂等键=fdId,20MB 封顶);付款申请详情动态追加特批附件展示行走 C103-5 代理下载;同步提交同链路配套修正:paymentLegal/result 回调改按合规特批流程ID(hcs_spec_fd_id)定位付款申请、C106-3 特批发起报文补充 fd_contract_plan 款项日期(履约计划行合规阶段ID);含 blade_payment_application 增量脚本
This commit is contained in:
刘泉佳
2026-10-27 15:12:02 +08:00
parent 4b9e117c9e
commit 80e56d01c9
10 changed files with 136 additions and 18 deletions
@@ -141,6 +141,8 @@ public class PaymentApplication extends TenantEntity {
private String hcsSpecFdId;
/** 特批状态:none/need/pushing/processing/approved/rejected */
private String hcsSpecStatus;
/** 合规付款特批结果附件ID(paymentLegal/result 回调 fileUrl,C103-5 下载入参、金蝶附件联带用) */
private String hcsSpecAttachFdId;
/** 履约记录ID(C108-1 返回,C108-2 更新必传) */
private String hcsPerfFdId;
}
@@ -190,6 +190,11 @@ public final class HcsSyncConstant {
*/
public static final String PAYMENT_ATTACH_TYPE_SPECIAL = "special_approval";
/**
* 付款申请附件类型值:合规特批结果附件(paymentLegal/result 回传展示行标记,详情展示用,不参与特批附件提交校验)
*/
public static final String PAYMENT_ATTACH_TYPE_SPEC_RESULT = "spec_result";
/**
* 附件类型:用印附件(双章归档文件,取值沿用 ContractManageServiceImpl#FILE_TYPE_SEAL_ARCHIVE)
*/
@@ -307,7 +307,7 @@ public class HcsSyncController {
* <p>
* 免登免授权同合同接口(blade.secure 放行);
* iPaaS 网关注册映射:/gwzh/HTHG/paymentLegal/result → /hcs/paymentLegal/result;
* 报文 {sourceSystem, requestId=TMS付款申请ID, status=APPROVED/REJECTED, reason},
* 报文 {sourceSystem, requestId=合规特批流程ID(hcs_spec_fd_id), status=APPROVED/REJECTED, reason},
* approved 恢复"审批通过"放行,rejected 联动付款审批驳回走重新提交闭环。
*/
@PostMapping("/paymentLegal/result")
@@ -116,6 +116,8 @@ import org.springframework.transaction.annotation.Transactional;
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.DateTimeFormatter;
@@ -261,6 +263,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
public PaymentApplicationVO detail(Long id) {
PaymentApplication entity = existing(id);
PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(entity);
// 合规特批结果附件动态追加展示(不落 attachmentsJson 原文,下载经 C103-5 代理透传)
this.appendSpecAttachDisplay(vo);
fillKingdeeLockStatus(List.of(vo));
vo.setInvoices(invoiceMapper.selectList(Wrappers.<PaymentApplicationInvoice>lambdaQuery()
.eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo)));
@@ -585,6 +589,34 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
}
/**
* 详情附件追加合规特批结果附件展示行(不落 attachmentsJson 原文):
* 前端通用附件列表直接渲染,下载经 /hcs/attachment/download(C103-5 代理)透传;
* 先剔除前端随单保存的历史展示行,避免重批后重复
*
* @param vo 付款申请详情 VO
*/
private void appendSpecAttachDisplay(PaymentApplicationVO vo) {
if (Func.isBlank(vo.getHcsSpecAttachFdId())) {
return;
}
try {
List<Object> rows = Func.isBlank(vo.getAttachmentsJson())
? new ArrayList<>() : new ArrayList<>(JsonUtil.parse(vo.getAttachmentsJson(), List.class));
rows.removeIf(row -> row instanceof Map<?, ?> map
&& HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT.equals(map.get("attachmentType")));
Map<String, Object> specRow = new LinkedHashMap<>();
specRow.put("name", "付款特批附件");
specRow.put("attachmentType", HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT);
specRow.put("url", "/hcs/attachment/download?fdId=" + vo.getHcsSpecAttachFdId()
+ "&name=" + URLEncoder.encode("付款特批附件", StandardCharsets.UTF_8));
rows.add(specRow);
vo.setAttachmentsJson(JsonUtil.toJson(rows));
} catch (Exception exception) {
log.warn("付款申请[{}]特批结果附件展示行追加失败", vo.getId(), exception);
}
}
/**
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,
* 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传;
@@ -599,8 +631,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id);
return;
}
kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
List<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> items = new ArrayList<>(
kingdeeAttachmentSyncSupport.parseUserAttachments(entity.getAttachmentsJson()));
// 合规付款特批结果附件(paymentLegal/result 回传):C103-5 按合规附件ID 下载后联带上传金蝶
if (Func.isNotBlank(entity.getHcsSpecAttachFdId())) {
items.add(KingdeeAttachmentSyncSupport.KingdeeAttachmentItem.hcsAttachment("付款特批附件", entity.getHcsSpecAttachFdId()));
}
kingdeeAttachmentSyncSupport.syncAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
id, entity.getKingdeeBillId(), items);
} catch (Exception exception) {
log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
}
@@ -37,6 +37,7 @@ 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.springblade.transport.support.hcs.HcsAttachmentSupport;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
@@ -64,9 +65,10 @@ import java.util.stream.Collectors;
* 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
* 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
* 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。</p>
* <p>附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
* <p>附件来源三类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url,仅接受 http(s) 绝对地址)、
* 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
* path 变化(红冲重开)视为新文件自动触发重传)。</p>
* path 变化(红冲重开)视为新文件自动触发重传)与
* HCS 合规附件(付款特批结果附件等,按合规附件ID 经 C103-5 下载,幂等键=fdId)。</p>
*
* @author Chill
*/
@@ -80,6 +82,7 @@ public class KingdeeAttachmentSyncSupport {
private final IBfiService bfiService;
private final KingdeeAttachmentSyncMapper attachmentSyncMapper;
private final HcsAttachmentSupport hcsAttachmentSupport;
/** 附件下载客户端(无状态,进程内复用) */
private final HttpClient downloadClient = HttpClient.newBuilder()
@@ -91,11 +94,30 @@ public class KingdeeAttachmentSyncSupport {
* 待同步附件项
*
* @param name 附件名称(含扩展名,展示/金蝶侧文件名)
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path;合规附件=合规附件ID fdId
* @param size 附件大小(字节,可空:空时下载后按实际字节校验)
* @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url
* @param hcsFdIdSource true 表示 key 为合规附件ID fdId(经 C103-5 下载)
*/
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource, boolean hcsFdIdSource) {
/**
* 兼容既有四参构造(默认非合规附件来源)
*/
public KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
this(name, key, size, kingdeePathSource, false);
}
/**
* HCS 合规附件项(C103-5 按合规附件ID 下载)
*
* @param name 附件名称
* @param fdId 合规附件ID
* @return 附件项
*/
public static KingdeeAttachmentItem hcsAttachment(String name, String fdId) {
return new KingdeeAttachmentItem(name, fdId, null, false, true);
}
}
/**
@@ -225,7 +247,8 @@ public class KingdeeAttachmentSyncSupport {
continue;
}
String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src");
if (Func.isBlank(url)) {
// 仅接受 http(s) 绝对地址:相对/代理路径(如合规特批附件展示行)无法直下,跳过防下载必败
if (Func.isBlank(url) || !(url.startsWith("http://") || url.startsWith("https://"))) {
continue;
}
String name = firstText(map, "originalName", "name", "fileName");
@@ -362,10 +385,18 @@ public class KingdeeAttachmentSyncSupport {
}
/**
* 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控);
* 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
* 拉取附件内容:合规附件经 C103-5 按合规附件ID 下载(流式读取,防大文件全量进内存失控);
* 星瀚侧文件经 A801-2 下载(流式读取);用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
*/
private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception {
if (item.hcsFdIdSource()) {
Response response = hcsAttachmentSupport.downloadRaw(item.key());
try (InputStream inputStream = response.body().asInputStream()) {
return readCapped(inputStream);
} finally {
response.close();
}
}
if (item.kingdeePathSource()) {
Response response = bfiService.downloadInvoice(item.key());
try (InputStream inputStream = response.body().asInputStream()) {
@@ -26,6 +26,7 @@
package org.springblade.transport.support.hcs;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
@@ -219,7 +220,7 @@ public class HcsPaymentSyncSupport {
/**
* 付款特批结果接收处理(合规 paymentLegal/result 回调通知特批审批结果):
* sourceSystem 须与 third-party.hcs.src-system 配置一致(不符抛出"来源系统标识错误"异常),
* requestId=TMS 付款申请ID、status=APPROVED/REJECTED;
* requestId=合规特批流程ID(hcs_spec_fd_id,C106-3 回存值)、status=APPROVED/REJECTED;
* 特批结果联动付款审批状态:approved 恢复"审批通过"放行(后续金蝶推送放行),
* rejected 联动付款审批驳回(returned),走"重新编辑→提交→审批→重新特批"闭环
*
@@ -250,6 +251,11 @@ public class HcsPaymentSyncSupport {
}
syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status));
payment.setHcsSpecStatus(status);
// 特批通过回传特批结果附件(fileUrl 值=合规附件ID):落库供 C103-5 下载与金蝶附件联带;
// 回调无附件时保留旧值(shortcut: 驳回重批期间旧附件仍展示,重批回调会覆盖,金蝶侧特批中门禁不会误传)
if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status) && StringUtil.isNotBlank(param.getFileUrl())) {
payment.setHcsSpecAttachFdId(param.getFileUrl().trim());
}
paymentApplicationMapper.updateById(payment);
// 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推
if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status)) {
@@ -288,17 +294,16 @@ public class HcsPaymentSyncSupport {
}
/**
* 按 TMS 付款申请ID 定位付款申请(合规推送 requestId)
* 按合规特批流程ID定位付款申请(合规推送 requestId=hcs_spec_fd_id,即 C106-3 返回并回存的流程 fdId;
* 与合同链路 locateContractByFdId 同口径,C106-3 发起报文未传 TMS 付款申请ID,合规无法回传数字ID)
*/
private PaymentApplication locatePayment(String requestId) {
if (StringUtil.isBlank(requestId)) {
return null;
}
try {
return paymentApplicationMapper.selectById(Long.valueOf(requestId.trim()));
} catch (NumberFormatException exception) {
return null;
}
return paymentApplicationMapper.selectOne(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getHcsSpecFdId, requestId.trim())
.last("limit 1"));
}
/**
@@ -42,7 +42,9 @@ import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.service.IContractExeStageService;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -74,6 +76,7 @@ public class HcsSpecPaySyncSupport {
private final HcsProperties hcsProperties;
private final ContractManageMapper contractManageMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final IContractExeStageService contractExeStageService;
private final HcsAttachmentSupport hcsAttachmentSupport;
/**
@@ -156,6 +159,8 @@ public class HcsSpecPaySyncSupport {
props.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus());
// 特批附件字段标识:固定 fd_spc_approval_attach
props.setFdSpcApprovalAttach(SPEC_ATTACH_ENTITY_KEY);
// 【必填】款项日期:传履约计划id(匹配付款计划行合规阶段ID,无关联不传,口径同 C108 履约记录)
props.setFdContractPlan(this.resolveStageFdId(payment));
formData.setDynamicProps(props);
// 【必填】表单层单据主题
formData.setFdDocSubject("结算付款特批-" + payment.getPaymentNo());
@@ -168,6 +173,18 @@ public class HcsSpecPaySyncSupport {
return param;
}
/**
* 匹配合同的付款履约计划行合规阶段ID(无匹配返回 null,DTO NON_NULL 序列化时不传,与 C108 履约记录口径一致)
*/
private String resolveStageFdId(PaymentApplication payment) {
if (payment.getContractId() == null) {
return null;
}
HcsContractExeStage stageRow = this.contractExeStageService.findPaymentStageRow(payment.getContractId(),
HcsSyncConstant.fundNatureOfPayment(payment.getPaymentType()));
return stageRow == null ? null : stageRow.getHcsStageFdId();
}
/**
* 特批附件挂载(附件转传开关关闭或无附件时返回 null)
*/
@@ -51,7 +51,7 @@ public class HcsLegalResultDTO implements Serializable {
*/
private String sourceSystem;
/**
* 定位单据ID(付款特批=TMS 付款申请ID;合同特批=合规合同ID,与 id 同值,按 blade_contract_manage.hcs_fd_id 匹配)
* 定位单据ID(付款特批=合规特批流程ID(hcs_spec_fd_id,C106-3 回存值);合同特批=合规合同ID,与 id 同值,按 blade_contract_manage.hcs_fd_id 匹配)
*/
private String requestId;
/**
@@ -66,4 +66,8 @@ public class HcsLegalResultDTO implements Serializable {
* 结果说明(驳回原因等,非必传)
*/
private String reason;
/**
* 特批结果附件(付款特批回传携带,值为合规附件ID,C103-5 下载入参,非必传)
*/
private String fileUrl;
}
@@ -145,4 +145,9 @@ public class HcsSpecPayPropsDTO implements Serializable {
*/
@JsonProperty("fd_spc_approval_attach")
private String fdSpcApprovalAttach;
/**
* 款项日期(传履约计划id,接口必填;无关联计划行不传,与 C108 履约记录口径一致)
*/
@JsonProperty("fd_contract_plan")
private String fdContractPlan;
}
@@ -0,0 +1,11 @@
-- 付款特批结果回传附件(paymentLegal/result 回调 fileUrl=合规附件ID):
-- blade_payment_application 追加合规特批结果附件ID 字段,供 C103-5 下载与金蝶附件联带上传。
-- 通过 information_schema 判断字段是否存在,可重复执行。
SET @db_name = DATABASE();
SET @sql = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_payment_application' AND column_name = 'hcs_spec_attach_fd_id'),
'SELECT 1',
'ALTER TABLE blade_payment_application ADD COLUMN hcs_spec_attach_fd_id varchar(128) DEFAULT NULL COMMENT ''合规付款特批结果附件ID(paymentLegal/result 回调 fileUrl,C103-5 下载入参)'''
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;