diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java
index 1d3ab279..655ae234 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java
@@ -220,4 +220,53 @@ public class ContractManage extends TenantEntity {
@Schema(description = "其他事项说明")
private String remark;
+ @Schema(description = "合规系统单据ID(fdContractId)")
+ private String hcsFdId;
+
+ @Schema(description = "合规同步状态 unsynced/synced/failed")
+ private String hcsSyncStatus;
+
+ @Schema(description = "合规推送失败重试次数")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer hcsRetryCount;
+
+ @Schema(description = "合规最近同步时间")
+ private LocalDateTime hcsSyncTime;
+
+ @Schema(description = "合规最近同步结果信息/失败原因")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String hcsSyncMsg;
+
+ @Schema(description = "合规履约计划ID(C103-9 返回 fdExeplanId)")
+ private String hcsExeplanId;
+
+ @Schema(description = "合规客商风险等级(C101-1,甲方/乙方取高)")
+ private String hcsRiskLevel;
+
+ @Schema(description = "合规客商黑名单状态(C101-1,甲方/乙方取是)")
+ private String hcsBlackStatus;
+
+ @Schema(description = "合规风险校验时间")
+ private LocalDateTime hcsRiskTime;
+
+ @Schema(description = "合规特批流程ID(C103-8 返回)")
+ private String hcsSpecFdId;
+
+ @Schema(description = "合规特批状态 none/need/processing/approved/rejected")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String hcsSpecStatus;
+
+ @Schema(description = "合规最近特批动作时间")
+ private LocalDateTime hcsSpecTime;
+
+ @Schema(description = "合规特批结果/发起失败原因")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String hcsSpecMsg;
+
+ @Schema(description = "特批申请材料+内部审批附件JSON(含C103-2 合规附件ID)")
+ private String hcsSpecFilesJson;
+
+ @Schema(description = "金蝶合同ID(contractLegal/result 合同特批金蝶同步结果回调回填)")
+ private String kingdeeId;
+
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsAttachmentSync.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsAttachmentSync.java
new file mode 100644
index 00000000..4fa7fb40
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsAttachmentSync.java
@@ -0,0 +1,78 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.core.tenant.mp.TenantEntity;
+
+import java.io.Serial;
+import java.time.LocalDateTime;
+
+/**
+ * HCS 附件转传映射实体类(C103-2 转传取得合规附件ID,单附件行级,照金蝶附件同步表设计)
+ *
+ * 仅落成功记录:HCS 转传任一附件失败即抛异常整单重试(失败原因落单据 hcs_sync_msg),故无失败态字段。
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_hcs_attachment_sync")
+@Schema(description = "HCS附件转传映射")
+public class HcsAttachmentSync extends TenantEntity {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 业务单据类型(contract/payment/invoice-application/invoice-receipt)
+ */
+ private String bizType;
+ /**
+ * 业务单据id
+ */
+ @JsonSerialize(using = ToStringSerializer.class)
+ private Long bizId;
+ /**
+ * 附件幂等键(url|link|fileUrl|downloadUrl|uid|originalName|name 依次取首个非空)
+ */
+ private String attachmentKey;
+ /**
+ * 附件名称(展示/排查用)
+ */
+ private String attachmentName;
+ /**
+ * 合规附件ID(C103-2 返回 fdId)
+ */
+ private String fdId;
+ /**
+ * 最近转传时间
+ */
+ private LocalDateTime syncTime;
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractCheckLog.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractCheckLog.java
new file mode 100644
index 00000000..f7815ad9
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractCheckLog.java
@@ -0,0 +1,79 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * HCS 风险/额度校验留痕实体类(照 ops 日志惯例,仅插入不改)
+ *
+ * @author Chill
+ */
+@Data
+@TableName("blade_hcs_check_log")
+@Schema(description = "HCS 风险/额度校验留痕")
+public class HcsContractCheckLog implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.ASSIGN_ID)
+ @Schema(description = "主键")
+ private Long id;
+
+ @Schema(description = "租户ID")
+ private String tenantId;
+
+ @Schema(description = "业务类型 project/contract/payment")
+ private String bizType;
+
+ @Schema(description = "业务单据ID")
+ private Long bizId;
+
+ @Schema(description = "校验类型 risk=客商风险/credit=额度")
+ private String checkType;
+
+ @Schema(description = "校验结果 pass=通过/fail=不通过/skip=跳过")
+ private String result;
+
+ @Schema(description = "校验说明")
+ private String msg;
+
+ @Schema(description = "合规系统链路追踪ID")
+ private String traceId;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractExeStage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractExeStage.java
new file mode 100644
index 00000000..eb42723f
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/HcsContractExeStage.java
@@ -0,0 +1,101 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
+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.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 合同履约规则实体类(HCS 履约阶段,设计文档 §4.1,ADR-0002)
+ *
+ * 合同维度的资金计划阶段,C103-9 行级同步的数据源:合同审批通过置 row_op=add,
+ * 后续编辑按行差异置 add/update/delete,由扫描任务随合同链路同步;
+ * 合规返回的阶段ID 必须持久化(hcsStageFdId),行更新/删除全靠它。
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_contract_exe_stage")
+@Schema(description = "合同履约规则(HCS 履约阶段)")
+public class HcsContractExeStage extends TenantEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @JsonSerialize(using = ToStringSerializer.class)
+ @Schema(description = "合同ID")
+ private Long contractId;
+
+ @Schema(description = "履约类型 payment=付款/collection=收款")
+ private String performType;
+
+ @Schema(description = "款项性质 prepayment/progress/acceptance/retention")
+ private String fundNature;
+
+ @Schema(description = "款项金额(元,付款审批通过按申请付款金额累加更新)")
+ private BigDecimal fundAmount;
+
+ @Schema(description = "计划资金日期")
+ private LocalDateTime fundDate;
+
+ @Schema(description = "开票规则(如 payFirst)")
+ private String invoiceRule;
+
+ @Schema(description = "账期")
+ private Integer invoiceCycle;
+
+ @Schema(description = "金额类型")
+ private String fundType;
+
+ @Schema(description = "是否完成")
+ private Integer isFinish;
+
+ @Schema(description = "行级同步操作 none/add/update/delete")
+ private String rowOp;
+
+ @Schema(description = "合规阶段ID(C103-9 返回,update/delete 必用)")
+ private String hcsStageFdId;
+
+ @Schema(description = "同步状态 unsynced/synced/failed")
+ private String hcsSyncStatus;
+
+ @Schema(description = "同步结果信息/失败原因")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String hcsSyncMsg;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
index 2b89b282..60ede7e1 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java
@@ -183,4 +183,14 @@ public class InvoiceApplication extends TenantEntity {
* 作废原因,驳回时记录驳回原因
*/
private String voidReason;
+ /** 合规系统单据ID(fdId) */
+ private String hcsFdId;
+ /** 合规同步状态:unsynced 待同步 / synced 已同步 / failed 同步失败 */
+ private String hcsSyncStatus;
+ /** 合规推送失败重试次数 */
+ private Integer hcsRetryCount;
+ /** 合规最近同步时间 */
+ private LocalDateTime hcsSyncTime;
+ /** 合规最近同步结果信息/失败原因 */
+ private String hcsSyncMsg;
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
index 9023e07e..91a33972 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java
@@ -180,4 +180,18 @@ public class InvoiceReceipt extends TenantEntity {
* 作废原因
*/
private String voidReason;
+ /** 合规系统单据ID(fdId) */
+ private String hcsFdId;
+ /** 合规同步状态:unsynced 待同步 / synced 已同步 / failed 同步失败 */
+ private String hcsSyncStatus;
+ /** 合规推送失败重试次数 */
+ private Integer hcsRetryCount;
+ /** 合规最近同步时间 */
+ private LocalDateTime hcsSyncTime;
+ /** 合规最近同步结果信息/失败原因 */
+ private String hcsSyncMsg;
+ /** 合规推送合同ID(应付结算回溯不到时人工补录,D13) */
+ private Long hcsContractId;
+ /** 合规推送合同编号(应付结算回溯不到时人工补录,D13) */
+ private String hcsContractNo;
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java
index 45ab1f4c..e4021397 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java
@@ -33,6 +33,7 @@ import org.springblade.core.tenant.mp.TenantEntity;
import java.io.Serial;
import java.math.BigDecimal;
import java.time.LocalDate;
+import java.time.LocalDateTime;
/** 付款申请实体。 @author Chill */
@Data
@@ -120,4 +121,26 @@ public class PaymentApplication extends TenantEntity {
private String attachmentsJson;
/** 备注 */
private String remark;
+ /** 合规系统单据ID(fdId) */
+ private String hcsFdId;
+ /** 合规同步状态:unsynced 待同步 / synced 已同步 / failed 同步失败 */
+ private String hcsSyncStatus;
+ /** 合规推送失败重试次数 */
+ private Integer hcsRetryCount;
+ /** 合规最近同步时间 */
+ private LocalDateTime hcsSyncTime;
+ /** 合规最近同步结果信息/失败原因 */
+ private String hcsSyncMsg;
+ /** 额度校验结果:pass 通过 / over 超额(C106-1) */
+ private String hcsCreditStatus;
+ /** 额度校验说明(超额原因 fdMessage) */
+ private String hcsCreditMsg;
+ /** 额度校验时间 */
+ private LocalDateTime hcsCreditTime;
+ /** 结算付款特批流程ID(C106-3 返回,C106-4 必传) */
+ private String hcsSpecFdId;
+ /** 特批状态:none/need/pushing/processing/approved/rejected */
+ private String hcsSpecStatus;
+ /** 履约记录ID(C108-1 返回,C108-2 更新必传) */
+ private String hcsPerfFdId;
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java
index eb85e2c4..46ede04c 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java
@@ -198,4 +198,27 @@ public class ProjectApply extends TenantEntity {
@Schema(description = "作废原因")
private String voidReason;
+ @Schema(description = "合规系统单据ID(fdId)")
+ private String hcsFdId;
+
+ @Schema(description = "合规同步状态 unsynced/synced/failed")
+ private String hcsSyncStatus;
+
+ @Schema(description = "合规推送失败重试次数")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private Integer hcsRetryCount;
+
+ @Schema(description = "合规最近同步时间")
+ private LocalDateTime hcsSyncTime;
+
+ @Schema(description = "合规最近同步结果信息/失败原因")
+ @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS)
+ private String hcsSyncMsg;
+
+ @Schema(description = "金蝶项目ID(D902 项目金蝶同步结果回调回填)")
+ private String kingdeeId;
+
+ @Schema(description = "金蝶项目编号(D902 项目金蝶同步结果回调回填)")
+ private String kingdeeNumber;
+
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java
index fc429ffe..5f76a439 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ContractManageVO.java
@@ -55,6 +55,14 @@ public class ContractManageVO extends ContractManage {
@Schema(description = "到期快捷筛选")
private String expireScope;
+ @TableField(exist = false)
+ @Schema(description = "是否已同步金蝶(金蝶合同ID是否已回填)")
+ private Boolean kingdeeSynced;
+
+ @TableField(exist = false)
+ @Schema(description = "是否已同步合规(合规同步状态是否为 synced)")
+ private Boolean hcsSynced;
+
@TableField(exist = false)
@Schema(description = "生效类型筛选")
private String effectiveType;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java
new file mode 100644
index 00000000..da93615f
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java
@@ -0,0 +1,72 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 客商风险展示 VO(C101-1 查询结果 + TMS 侧组装提示,前端接入设计 §3.3)
+ *
+ * 内部组织类型客商默认无风险(不调 C101-1);外部客商高风险/黑名单时 risky=true 并返回 riskTip
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "客商风险展示")
+public class CustomerRiskVO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "客商编号")
+ private String customerCode;
+
+ @Schema(description = "客商类型:internal内部组织(默认无风险)/external外部客商")
+ private String customerKind;
+
+ @Schema(description = "客商名称")
+ private String customerName;
+
+ @Schema(description = "黑名单状态:yes/no")
+ private String fdBlackStatus;
+
+ @Schema(description = "风险等级:0=低风险,1=中风险,2=高风险")
+ private String fdRiskLevel;
+
+ @Schema(description = "风险等级名称(服务端转义:黑名单/高风险/中风险/低风险,黑名单优先;无等级为空)")
+ private String riskLevelName;
+
+ @Schema(description = "是否高风险/黑名单客商(外部客商专用,内部组织恒为 false)")
+ private Boolean risky;
+
+ @Schema(description = "风险客商提示文案(基本信息备注后展示,安全客商为空)")
+ private String riskTip;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectApplyVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectApplyVO.java
index aed01d69..c4cd0fa6 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectApplyVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectApplyVO.java
@@ -86,6 +86,14 @@ public class ProjectApplyVO extends ProjectApply {
@Schema(description = "是否仅查询可用于合同选择的项目(含临时项目与正式审批通过项目)")
private Boolean contractSelectable;
+ @TableField(exist = false)
+ @Schema(description = "是否已同步金蝶(金蝶项目ID是否已回填)")
+ private Boolean kingdeeSynced;
+
+ @TableField(exist = false)
+ @Schema(description = "是否已同步合规(合规同步状态是否为 synced)")
+ private Boolean hcsSynced;
+
@TableField(exist = false)
@Schema(description = "资金使用风险等级:high、medium、none")
private String fundUseRisk;
diff --git a/blade-service/blade-transport/pom.xml b/blade-service/blade-transport/pom.xml
index 02e9bab8..a8b4c713 100644
--- a/blade-service/blade-transport/pom.xml
+++ b/blade-service/blade-transport/pom.xml
@@ -51,6 +51,10 @@
org.springblade
blade-bfi-api
+
+ org.springblade
+ blade-hcs-api
+
org.springframework.boot
spring-boot-starter-amqp
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java
index 760d80ec..5fe089fe 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java
@@ -28,6 +28,7 @@ package org.springblade.transport.config;
import lombok.Data;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
@@ -45,6 +46,7 @@ import java.util.Map;
*/
@Data
@Component
+@RefreshScope
@ConfigurationProperties(prefix = "thirdparty.bfi")
public class BfiProperties {
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java
new file mode 100644
index 00000000..798c77d1
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsContractFieldConstant.java
@@ -0,0 +1,223 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.constant;
+
+/**
+ * HCS(合同合规系统)C103-1/C103-11 合同发起 xformFields 字段名常量
+ *
+ * 字段名取自 v4 接口文档「合同字段说明」,fdSepcailContract 为文档原文拼写(Special 笔误,必须沿用);
+ * 命名照 {@link HcsSyncConstant} 惯例。
+ *
+ * @author Chill
+ */
+public final class HcsContractFieldConstant {
+
+ // ==================== 合同发起 xformFields 字段名(fdXformField.name) ====================
+
+ /**
+ * 合同ID(更新时传 C103-1 返回的 fdContractId)
+ */
+ public static final String FIELD_FD_ID = "fdId";
+
+ /**
+ * 合同编号(fd 模板字段)
+ */
+ public static final String FIELD_FD_NUMBER = "fdNumber";
+
+ /**
+ * 合同编号(CONTRACT 模板字段)
+ */
+ public static final String FIELD_CONTRACT_NO = "CONTRACT_NO";
+
+ /**
+ * 合同标题(fd 模板字段)
+ */
+ public static final String FIELD_FD_SUBJECT = "fdSubject";
+
+ /**
+ * 合同名称(CONTRACT 模板字段)
+ */
+ public static final String FIELD_CONTRACT_NAME = "CONTRACT_NAME";
+
+ /**
+ * 合同金额(fd 模板字段)
+ */
+ public static final String FIELD_FD_AMOUNT = "fdAmount";
+
+ /**
+ * 合同金额(AMOUNT 模板字段)
+ */
+ public static final String FIELD_AMOUNT = "AMOUNT";
+
+ /**
+ * 币种(fd 模板字段)
+ */
+ public static final String FIELD_FD_CURRENCY_ID = "fdCurrencyId";
+
+ /**
+ * 币种(CURRENCY 模板字段)
+ */
+ public static final String FIELD_CURRENCY = "CURRENCY";
+
+ /**
+ * 合同结算类型:payment=付款,payment_collection=收支双向,collection=收款,none=不结算
+ */
+ public static final String FIELD_FD_SETTLEMENT_TYPE = "fdSettlementType";
+
+ /**
+ * 特批流程(文档原文拼写笔误,必须沿用)
+ */
+ public static final String FIELD_FD_SEPCAIL_CONTRACT = "fdSepcailContract";
+
+ /**
+ * 合同类型
+ */
+ public static final String FIELD_CONTRACT_TYPE = "CONTRACT_TYPE";
+
+ /**
+ * 甲方名称
+ */
+ public static final String FIELD_PARTY_A = "PARTY_A";
+
+ /**
+ * 乙方名称
+ */
+ public static final String FIELD_PARTY_B = "PARTY_B";
+
+ /**
+ * 签订日期(yyyy-MM-dd,fdType=timestamp)
+ */
+ public static final String FIELD_SIGN_DATE = "SIGN_DATE";
+
+ /**
+ * 预计开始时间(合同开始期限为指定日期时必填)
+ */
+ public static final String FIELD_FD_ESTIMATE_START_TIME = "fdEstimateStartTime";
+
+ /**
+ * 预计结束时间(合同结束期限为指定日期时必填)
+ */
+ public static final String FIELD_FD_ESTIMATE_END_TIME = "fdEstimateEndTime";
+
+ /**
+ * 合同开始期限:signDate=签订之日起算,specifiedDate=指定日期
+ */
+ public static final String FIELD_FD_START_TERM = "fdStartTerm";
+
+ /**
+ * 合同结束期限:signTerm=按签订后期限,specifiedDate=指定日期,noTerm=无固定期限
+ */
+ public static final String FIELD_FD_END_TERM = "fdEndTerm";
+
+ /**
+ * 创建时间(yyyy-MM-dd,fdType=timestamp)
+ */
+ public static final String FIELD_CREATE_TIME = "CREATE_TIME";
+
+ /**
+ * 创建时间(fd 模板字段,yyyy-MM-dd,fdType=timestamp)
+ */
+ public static final String FIELD_FD_CREATE_TIME = "fdCreateTime";
+
+ /**
+ * 同步时间(yyyy-MM-dd,fdType=timestamp)
+ */
+ public static final String FIELD_FD_SYN_TIME = "fd_syn_time";
+
+ /**
+ * 承办人名称
+ */
+ public static final String FIELD_CREATE_USER_NAME = "CREATE_USER_NAME";
+
+ /**
+ * 合同状态:draft/approving/approved 等(v4 必填)
+ */
+ public static final String FIELD_CONTRACT_STATUS = "CONTRACT_STATUS";
+
+ /**
+ * 合同份数(v4 必填)
+ */
+ public static final String FIELD_FD_SERVINGS = "fdServings";
+
+ /**
+ * 合同说明(选填)
+ */
+ public static final String FIELD_FD_DESC = "fdDesc";
+
+ /**
+ * 关联项目(选填,对象传值如 {"fdId": "项目id"},传合规项目ID)
+ */
+ public static final String FIELD_FD_PROJECT = "fdProject";
+
+ /**
+ * 合同结算模式:open=开口合同,close=闭口合同
+ */
+ public static final String FIELD_FD_CONTRACT_GW_NATURE = "fdContractGwNature";
+
+ // ==================== 结算类型枚举值 ====================
+
+ /**
+ * 合同结算类型取值:付款(fdSettlementType,承运商合同固定取此值;合规系统字典中文名,2026-10-17 确认)
+ */
+ public static final String SETTLEMENT_TYPE_PAYMENT = "付款";
+
+ /**
+ * 合同结算类型取值:收支双向(fdSettlementType,合规系统字典中文名)
+ */
+ public static final String SETTLEMENT_TYPE_PAYMENT_COLLECTION = "收支双向";
+
+ /**
+ * 合同结算类型取值:收款(fdSettlementType,客户合同固定取此值;合规系统字典中文名)
+ */
+ public static final String SETTLEMENT_TYPE_COLLECTION = "收款";
+
+ /**
+ * 合同结算类型取值:不结算(fdSettlementType,合规系统字典中文名)
+ */
+ public static final String SETTLEMENT_TYPE_NONE = "不结算";
+
+ // ==================== 结算模式枚举值 ====================
+
+ /**
+ * 合同结算模式取值:开口合同(fdContractGwNature,TMS 固定取此值)
+ */
+ public static final String CONTRACT_GW_NATURE_OPEN = "open";
+
+ /**
+ * 合同结算模式取值:闭口合同(fdContractGwNature)
+ */
+ public static final String CONTRACT_GW_NATURE_CLOSE = "close";
+
+ // ==================== 期限枚举值 ====================
+
+ /**
+ * 期限取值:指定日期(fdStartTerm/fdEndTerm,TMS 固定按指定日期起算)
+ */
+ public static final String TERM_SPECIFIED_DATE = "specifiedDate";
+
+ private HcsContractFieldConstant() {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsFieldConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsFieldConstant.java
new file mode 100644
index 00000000..749aed1c
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsFieldConstant.java
@@ -0,0 +1,116 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.constant;
+
+/**
+ * HCS(合同合规系统)列表查询 / 回调字段名常量(TMS 业务侧)
+ *
+ * 与 {@link HcsContractFieldConstant}(C103-1/C103-11 表单字段)区分;
+ * 项目立项查询用下划线字段名,合同/开票查询用驼峰字段名,以实际接口为准。
+ *
+ * @author Chill
+ */
+public final class HcsFieldConstant {
+
+ // ==================== C102-3 项目立项列表查询(下划线字段名) ====================
+
+ /**
+ * 项目ID
+ */
+ public static final String PROJ_FD_ID = "fd_id";
+
+ /**
+ * 项目编号(判重条件键)
+ */
+ public static final String PROJ_FD_NUM = "fd_num";
+
+ /**
+ * 项目名称
+ */
+ public static final String PROJ_FD_PRO_NAME = "fd_pro_name";
+
+ /**
+ * 创建时间(排序字段)
+ */
+ public static final String PROJ_FD_CREATE_TIME = "fd_create_time";
+
+ // ==================== C103-3 合同列表查询(驼峰字段名) ====================
+
+ /**
+ * 合同ID
+ */
+ public static final String CONTRACT_FD_ID = "fdId";
+
+ /**
+ * 合同标题
+ */
+ public static final String CONTRACT_FD_SUBJECT = "fdSubject";
+
+ /**
+ * 合同编号(查询条件键)
+ */
+ public static final String CONTRACT_FD_NUMBER = "fdNumber";
+
+ /**
+ * 合同分类层级ID
+ */
+ public static final String CONTRACT_FD_CATE_HIERARCHY_ID = "fdCateHierarchyId";
+
+ // ==================== C111-1 开票登记列表查询(驼峰字段名) ====================
+
+ /**
+ * 单据ID
+ */
+ public static final String INVOICE_FD_ID = "fdId";
+
+ /**
+ * 单据编号(查询条件键)
+ */
+ public static final String INVOICE_FD_NUMBER = "fdNumber";
+
+ /**
+ * 开票日期
+ */
+ public static final String INVOICE_FD_BILL_DATE = "fdBillDate";
+
+ /**
+ * 合同编号
+ */
+ public static final String INVOICE_FD_CONTRACT_NUMBER = "fdContractNumber";
+
+ /**
+ * 开票金额
+ */
+ public static final String INVOICE_FD_INVOICE_AMOUNT = "fdInvoiceAmount";
+
+ /**
+ * 单据状态
+ */
+ public static final String INVOICE_FD_STATUS = "fdStatus";
+
+ private HcsFieldConstant() {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java
new file mode 100644
index 00000000..42eaaf09
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/HcsSyncConstant.java
@@ -0,0 +1,342 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.constant;
+
+/**
+ * HCS(合同合规系统)同步常量(TMS 业务侧)
+ *
+ * 命名照 {@link KingdeeBfiConstant} 惯例;与客户端模块 org.springblade.hcs.constant.HcsConstant 区分。
+ *
+ * @author Chill
+ */
+public final class HcsSyncConstant {
+
+ /**
+ * 同步状态:待同步
+ */
+ public static final String HCS_STATUS_UNSYNCED = "unsynced";
+
+ /**
+ * 同步状态:已同步
+ */
+ public static final String HCS_STATUS_SYNCED = "synced";
+
+ /**
+ * 同步状态:同步失败
+ */
+ public static final String HCS_STATUS_FAILED = "failed";
+
+ /**
+ * 推送业务类型:项目立项(C102)
+ */
+ public static final String BIZ_TYPE_PROJECT = "project";
+
+ /**
+ * 推送业务类型:合同(C103)
+ */
+ public static final String BIZ_TYPE_CONTRACT = "contract";
+
+ /**
+ * 推送业务类型:付款申请(C106 特批 / C108 履约记录)
+ */
+ public static final String BIZ_TYPE_PAYMENT = "payment";
+
+ /**
+ * 推送业务类型:开票申请(C111 开票登记)
+ */
+ public static final String BIZ_TYPE_INVOICE_APPLICATION = "invoice-application";
+
+ /**
+ * 推送业务类型:收票单(C109)
+ */
+ public static final String BIZ_TYPE_INVOICE_RECEIPT = "invoice-receipt";
+
+ /**
+ * 校验类型:客商风险(C101-1)
+ */
+ public static final String CHECK_TYPE_RISK = "risk";
+
+ /**
+ * 校验类型:额度(C106-1,P3 付款链使用)
+ */
+ public static final String CHECK_TYPE_CREDIT = "credit";
+
+ /**
+ * 校验类型:合同特批(合规特批结果推送/查询联动留痕;原 C103-8 发起留痕随接口删除)
+ */
+ public static final String CHECK_TYPE_SPEC = "spec";
+
+ /**
+ * 校验类型:项目金蝶同步结果回调(D902,projectLegal/result 推送留痕)
+ */
+ public static final String CHECK_TYPE_KINGDEE = "kingdee";
+
+ /**
+ * 校验结果:通过
+ */
+ public static final String CHECK_RESULT_PASS = "pass";
+
+ /**
+ * 校验结果:不通过(黑名单)
+ */
+ public static final String CHECK_RESULT_FAIL = "fail";
+
+ /**
+ * 校验结果:跳过(客商编号反查不到等)
+ */
+ public static final String CHECK_RESULT_SKIP = "skip";
+
+ // ==================== 额度校验结果(C106-1,D15) ====================
+
+ /**
+ * 额度校验通过
+ */
+ public static final String CREDIT_STATUS_PASS = "pass";
+
+ /**
+ * 额度校验超额(不拦截提交,转入特批链路)
+ */
+ public static final String CREDIT_STATUS_OVER = "over";
+
+ // ==================== 特批状态(hcs_spec_status,D15) ====================
+
+ /**
+ * 特批状态:无需特批
+ */
+ public static final String SPEC_STATUS_NONE = "none";
+
+ /**
+ * 特批状态:待特批(高风险/黑名单或额度超额标记,扫描任务据此发起 C106-3)
+ */
+ public static final String SPEC_STATUS_NEED = "need";
+
+ /**
+ * 特批状态:发起中
+ */
+ public static final String SPEC_STATUS_PUSHING = "pushing";
+
+ /**
+ * 特批状态:特批中(C106-3 已发起,TMS 挂起「特批审核」,等待合规 paymentLegal/result 回调)
+ */
+ public static final String SPEC_STATUS_PROCESSING = "processing";
+
+ /**
+ * 特批状态:特批通过(合规回调/人工确认,联动恢复"审批通过"放行)
+ */
+ public static final String SPEC_STATUS_APPROVED = "approved";
+
+ /**
+ * 特批状态:特批驳回(合规回调/人工确认,联动付款审批驳回走重新提交闭环)
+ */
+ public static final String SPEC_STATUS_REJECTED = "rejected";
+
+ /**
+ * 合规侧特批结果回传状态:通过(contractLegal/paymentLegal result 接口 status,D15 回传机制)
+ */
+ public static final String LEGAL_RESULT_APPROVED = "APPROVED";
+
+ /**
+ * 合规侧特批结果回传状态:驳回
+ */
+ public static final String LEGAL_RESULT_REJECTED = "REJECTED";
+
+ // ==================== 客商风险(C101-1,外部客商高风险/黑名单管控) ====================
+
+ /**
+ * C101-1 风险等级:高风险(0=低风险,1=中风险,2=高风险)
+ */
+ public static final String RISK_LEVEL_HIGH = "2";
+
+ /**
+ * 客商类型:内部组织(默认无风险,不查 C101-1)
+ */
+ public static final String CUSTOMER_KIND_INTERNAL = "internal";
+
+ /**
+ * 合同附件类型字段名(与前端附件元数据 fileType 字段对齐,值取业务字典 contract_attachment_type)
+ */
+ public static final String ATTACHMENT_TYPE_FIELD = "fileType";
+
+ /**
+ * 附件类型:特批附件(高风险/黑名单客商合同必须上传;值取业务字典 contract_attachment_type,中文匹配沿用 FILE_TYPE_SEAL_ARCHIVE="双章归档文件" 惯例)
+ */
+ public static final String ATTACHMENT_TYPE_SPEC_EXPLANATION = "特批附件";
+
+ /**
+ * 付款申请附件类型值:特批附件(前端付款表单附件类型枚举 attachmentType,付款附件结构与合同 fileType 结构不同)
+ */
+ public static final String PAYMENT_ATTACH_TYPE_SPECIAL = "special_approval";
+
+ /**
+ * 附件类型:用印附件(双章归档文件,取值沿用 ContractManageServiceImpl#FILE_TYPE_SEAL_ARCHIVE)
+ */
+ public static final String ATTACHMENT_TYPE_SEAL = "双章归档文件";
+
+ // ==================== 合同附件挂载 fdEntityKey(eopCdraft 附件面板字段 key,C103-1) ====================
+
+ /**
+ * 合同附件字段 key:特批附件
+ */
+ public static final String CONTRACT_ATTACH_KEY_SPECIAL = "eopCdraftSpecial";
+
+ /**
+ * 合同附件字段 key:用印附件
+ */
+ public static final String CONTRACT_ATTACH_KEY_SEAL = "eopCdraftSign";
+
+ /**
+ * 合同附件字段 key:其他附件(含合同主文件)
+ */
+ public static final String CONTRACT_ATTACH_KEY_OTHER = "eopCdraftOther";
+
+ /**
+ * 特批审核挂起节点展示名(currentNode,特批发起后挂起展示,结果回传后恢复)
+ */
+ public static final String SPEC_REVIEW_NODE = "特批审核";
+
+ // ==================== 履约计划行(blade_contract_exe_stage,C103-9) ====================
+
+ /**
+ * 行级同步操作:无(同步成功后清除标记)
+ */
+ public static final String ROW_OP_NONE = "none";
+
+ /**
+ * 行级同步操作:新增
+ */
+ public static final String ROW_OP_ADD = "add";
+
+ /**
+ * 行级同步操作:更新
+ */
+ public static final String ROW_OP_UPDATE = "update";
+
+ /**
+ * 行级同步操作:删除
+ */
+ public static final String ROW_OP_DELETE = "delete";
+
+ /**
+ * 履约类型:付款
+ */
+ public static final String PERFORM_TYPE_PAYMENT = "payment";
+
+ /**
+ * 履约类型:收款
+ */
+ public static final String PERFORM_TYPE_COLLECTION = "collection";
+
+ /**
+ * 款项性质:进度款
+ */
+ public static final String FUND_NATURE_PROGRESS = "progress";
+
+ /**
+ * 款项性质:预付款
+ */
+ public static final String FUND_NATURE_PREPAYMENT = "prepayment";
+
+ /**
+ * 付款类型→款项性质映射:正式结算付款对应进度款计划行,预付款(进度预付/项目预付)对应预付款计划行
+ *
+ * @param paymentType 付款申请付款类型
+ * @return 款项性质
+ */
+ public static String fundNatureOfPayment(String paymentType) {
+ return TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(paymentType) ? FUND_NATURE_PROGRESS : FUND_NATURE_PREPAYMENT;
+ }
+
+ // ==================== Redis 缓存 key(照 BFI blade:third: 前缀惯例) ====================
+
+ /**
+ * 主数据缓存:合同模板列表
+ */
+ public static final String MASTER_CACHE_TEMPLATES = "blade:third:hcs:master:templates";
+
+ /**
+ * 主数据缓存:合同分类列表
+ */
+ public static final String MASTER_CACHE_CATEGORIES = "blade:third:hcs:master:categories";
+
+ /**
+ * 主数据缓存:币种列表
+ */
+ public static final String MASTER_CACHE_CURRENCIES = "blade:third:hcs:master:currencies";
+
+ /**
+ * 推送任务分布式锁 key 前缀(多实例部署防重扫,完整 key = 前缀 + 业务类型,任务间互不阻塞)
+ */
+ public static final String PUSH_TASK_LOCK_KEY_PREFIX = "blade:third:hcs:push:task:lock:";
+
+ /**
+ * 即时推送单条锁 key 前缀(审批通过即时推送防与扫描任务并发双推,完整 key = 前缀 + 业务类型 + ":" + 单据ID)
+ */
+ public static final String PUSH_SINGLE_LOCK_KEY_PREFIX = "blade:third:hcs:push:lock:";
+
+ /**
+ * 即时推送单条锁 TTL(秒):大于单条推送预期时长,异常残留锁自动过期后由扫描兜底
+ */
+ public static final long PUSH_SINGLE_LOCK_TTL_SECONDS = 120L;
+
+ /**
+ * hcs_sync_msg 字段安全截断长度(列宽 varchar(512),留余量防远程错误报文超长导致落库失败)
+ */
+ public static final int SYNC_MSG_MAX_LENGTH = 500;
+
+ /**
+ * 截断超长的推送结果/失败原因(如 Feign 异常携带的远程 404 响应体),防止 Data too long 落库失败
+ */
+ public static String truncateMsg(String message) {
+ if (message == null) {
+ return null;
+ }
+ return message.length() > SYNC_MSG_MAX_LENGTH ? message.substring(0, SYNC_MSG_MAX_LENGTH) : message;
+ }
+
+ /**
+ * 审批状态中文名(变更记录展示用,金蝶同步结果回调等系统链路共用;未匹配回退原值)
+ */
+ public static String approvalStatusName(String approvalStatus) {
+ if (approvalStatus == null || approvalStatus.isEmpty()) {
+ return null;
+ }
+ return switch (approvalStatus) {
+ case "draft" -> "草稿";
+ case "reviewing" -> "审批中";
+ case "withdrawn" -> "已撤回";
+ case "approved" -> "审批通过";
+ case "rejected" -> "已驳回";
+ case "change_reviewing" -> "变更审批中";
+ case "change_rejected" -> "变更驳回";
+ case "change_approved" -> "变更审批通过";
+ case "voided" -> "已作废";
+ default -> approvalStatus;
+ };
+ }
+
+ private HcsSyncConstant() {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractExeStageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractExeStageController.java
new file mode 100644
index 00000000..17eae220
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractExeStageController.java
@@ -0,0 +1,91 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
+import org.springblade.core.tool.api.R;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
+import org.springblade.transport.service.IContractExeStageService;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 合同履约规则维护端点(合同表单"履约规则"页签,设计文档 §4.1,ADR-0002)
+ *
+ * 行级差异(add/update/delete)由本端点落库标记,行级同步由扫描任务随合同链路调 C103-9 完成;
+ * 行删除为软删+标记,不可物理删除。
+ *
+ * @author Chill
+ */
+@RestController
+@AllArgsConstructor
+@RequestMapping("contract-exe-stage")
+@Tag(name = "合同履约规则", description = "合同履约规则维护(HCS 履约阶段同步数据源)")
+public class ContractExeStageController {
+
+ private final IContractExeStageService contractExeStageService;
+
+ /**
+ * 按合同查询履约规则行
+ */
+ @GetMapping("/list")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "按合同查询履约规则", description = "传入合同ID,返回规则行列表")
+ public R> list(@Parameter(description = "合同ID", required = true) @RequestParam Long contractId) {
+ return R.data(contractExeStageService.listByContractId(contractId));
+ }
+
+ /**
+ * 保存履约规则行(整批提交,按行差异置同步标记)
+ */
+ @PostMapping("/submit")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "保存履约规则", description = "传入合同ID与规则行列表,新行/未同步行标记 add、已同步行标记 update")
+ public R submit(@Parameter(description = "合同ID", required = true) @RequestParam Long contractId,
+ @RequestBody List rows) {
+ return R.status(contractExeStageService.submitRows(contractId, rows));
+ }
+
+ /**
+ * 删除履约规则行(软删+标记,已同步行待 C103-9 上报删除)
+ */
+ @PostMapping("/remove")
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "删除履约规则行", description = "传入规则行ID,软删并标记 delete 待同步")
+ public R remove(@Parameter(description = "规则行ID", required = true) @RequestParam Long id) {
+ return R.status(contractExeStageService.removeRow(id));
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java
index 3ff3eaf1..364dbcc9 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java
@@ -40,6 +40,7 @@ import org.springblade.transport.excel.ContractManageExcel;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.vo.ContractManageVO;
import org.springblade.transport.service.IContractManageService;
+import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -63,6 +64,7 @@ import java.util.Map;
public class ContractManageController extends BladeController {
private final IContractManageService contractManageService;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@GetMapping("/detail")
@ApiOperationSupport(order = 1)
@@ -110,7 +112,14 @@ public class ContractManageController extends BladeController {
@ApiOperationSupport(order = 7)
@Operation(summary = "审批通过", description = "传入id")
public R approve(@Parameter(description = "主键", required = true) @RequestParam Long id) {
- return R.status(contractManageService.approve(id));
+ contractManageService.approve(id);
+ // 审批事务已提交,再同步推送合规;失败原因以警告文案返回(data=false 供前端区分提示样式),照正式结算单审批接口
+ hcsImmediatePushSupport.pushContractAndWait(id);
+ String syncFailure = HcsImmediatePushSupport.pollSyncFailure();
+ if (syncFailure != null) {
+ return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure);
+ }
+ return R.success("审批通过");
}
@PostMapping("/reject")
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java
index 3ec0e5fe..d128ed17 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java
@@ -204,12 +204,16 @@ public class FormalSettlementController extends BladeController {
return approveResponse();
}
- /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
+ /** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
+ String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
+ if (syncResult != null) {
+ return R.success("审批通过,已同步金蝶:" + syncResult);
+ }
return R.success("审批通过");
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java
deleted file mode 100644
index 247f0e87..00000000
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsMockController.java
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * BladeX Commercial License Agreement
- * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
- *
- * Use of this software is governed by the Commercial License Agreement
- * obtained after purchasing a license from BladeX.
- *
- * 1. This software is for development use only under a valid license
- * from BladeX.
- *
- * 2. Redistribution of this software's source code to any third party
- * without a commercial license is strictly prohibited.
- *
- * 3. Licensees may copyright their own code but cannot use segments
- * from this software for such purposes. Copyright of this software
- * remains with BladeX.
- *
- * Using this software signifies agreement to this License, and the software
- * must not be used for illegal purposes.
- *
- * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
- * not liable for any claims arising from secondary or illegal development.
- *
- * Author: Chill Zhuang (bladejava@qq.com)
- */
-package org.springblade.transport.controller;
-
-import cn.hutool.core.util.StrUtil;
-import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.extern.slf4j.Slf4j;
-import org.springblade.core.log.exception.ServiceException;
-import org.springblade.core.tenant.annotation.TenantIgnore;
-import org.springblade.core.tool.api.R;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-import java.util.Map;
-import java.util.Objects;
-
-/**
- * HCS 合规回调 Mock 端点
- *
- * 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
- * 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
- * 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
- * {@link } 并调用 Support 方法即可。
- *
- * @author Chill
- */
-@Slf4j
-@RestController
-@TenantIgnore
-@RequestMapping("hcs")
-@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
-public class HcsMockController {
-
- /**
- * 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
- */
- private static final String SOURCE_SYSTEM = "WLTMS";
-
- /**
- * 合同法务审批结果回调(D103)
- */
- @PostMapping("/contractLegal/result")
- @ApiOperationSupport(order = 15)
- @Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
- public R contractLegalResult(@RequestBody(required = false) Map param) {
- this.checkSourceSystem("合同法务审批结果", param);
- return R.data("mock特批结果已处理:approved");
- }
-
- /**
- * 付款法务审批结果回调(D104)
- */
- @PostMapping("/paymentLegal/result")
- @ApiOperationSupport(order = 16)
- @Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
- public R paymentLegalResult(@RequestBody(required = false) Map param) {
- this.checkSourceSystem("付款法务审批结果", param);
- return R.data("mock付款特批结果接收已处理:approved");
- }
-
- /**
- * 项目金蝶同步结果回调(D902)
- */
- @PostMapping("/projectLegal/result")
- @ApiOperationSupport(order = 18)
- @Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
- public R projectLegalResult(@RequestBody(required = false) Map param) {
- this.checkSourceSystem("项目金蝶同步结果", param);
- return R.data("mock项目金蝶同步结果接收已处理:approved");
- }
-
- /**
- * 合同金蝶同步结果回传接收(D903)
- */
- @PostMapping("/contractKingdee/result")
- @ApiOperationSupport(order = 19)
- @Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
- public R contractKingdeeResult(@RequestBody(required = false) Map param) {
- this.checkSourceSystem("合同金蝶同步结果", param);
- return R.data("mock合同金蝶同步结果接收处理:approved");
- }
-
- /**
- * 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
- *
- * @param bizName 业务名称(日志用)
- * @param param 回调报文
- */
- private void checkSourceSystem(String bizName, Map param) {
- String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
- if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
- log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
- throw new ServiceException("来源系统标识错误");
- }
- }
-}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java
new file mode 100644
index 00000000..e192d772
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java
@@ -0,0 +1,349 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import feign.Response;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.secure.utils.AuthUtil;
+import org.springblade.core.tenant.annotation.TenantIgnore;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.dto.HcsContractLegalResultDTO;
+import org.springblade.hcs.dto.HcsCurrencyVO;
+import org.springblade.hcs.dto.HcsLegalResultDTO;
+import org.springblade.hcs.dto.HcsProjectLegalResultDTO;
+import org.springblade.hcs.dto.HcsTemplateVO;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.pojo.vo.CustomerRiskVO;
+import org.springblade.transport.support.hcs.HcsAttachmentSupport;
+import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport;
+import org.springblade.transport.support.hcs.HcsContractSyncSupport;
+import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
+import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
+import org.springblade.transport.support.hcs.HcsMasterDataSupport;
+import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
+import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
+import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
+import org.springblade.transport.task.HcsPushTask;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * HCS 合规推送端点(设计文档 §5.0 手动入口)
+ *
+ * 前端列表页"推送合规/重推"按钮(照"同步金蝶"按钮模式):
+ * 单条重推重置 hcs_sync_status=unsynced 并清零重试计数后立即推送;批量端点复用定时扫描逻辑。
+ * 排查推送问题以 hcs_sync_status/hcs_sync_msg 为准,不以审批日志为准(ADR-0001)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@RestController
+@AllArgsConstructor
+@TenantIgnore
+@RequestMapping("hcs")
+@Tag(name = "HCS合规推送", description = "HCS合规推送端点")
+public class HcsSyncController {
+
+ private final HcsPushTask hcsPushTask;
+ private final HcsProjectSyncSupport hcsProjectSyncSupport;
+ private final HcsContractSyncSupport hcsContractSyncSupport;
+ private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport;
+ private final HcsCreditCheckSupport hcsCreditCheckSupport;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+ private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
+ private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
+ private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
+ private final HcsMasterDataSupport hcsMasterDataSupport;
+
+ /**
+ * 单条重推(bizType:project=项目立项 / contract=合同 / payment=付款申请 / invoice-application=开票申请 / invoice-receipt=收票单)
+ */
+ @PostMapping("/sync/{bizType}/{id}")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "单条推送/重推合规", description = "重置待同步状态后立即推送,bizType:project、contract、payment、invoice-application、invoice-receipt")
+ public R repush(@Parameter(description = "业务类型", required = true) @PathVariable String bizType,
+ @Parameter(description = "单据ID", required = true) @PathVariable Long id) {
+ String result = switch (bizType) {
+ case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsProjectSyncSupport.repushProject(id);
+ case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsContractSyncSupport.repushContract(id);
+ case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPaymentSyncSupport.repushPayment(id);
+ case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsInvoiceRegSyncSupport.repushInvoiceRegistration(id);
+ case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsReceiptBillSyncSupport.repushReceiptBill(id);
+ default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType);
+ };
+ return R.data(result);
+ }
+
+ /**
+ * 批量推送(复用定时扫描逻辑,单轮批量上限 push.batch-size)
+ */
+ @PostMapping("/sync/{bizType}")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "批量推送合规", description = "扫描待同步/待补偿单据推送一轮,bizType:project、contract、payment、invoice-application、invoice-receipt")
+ public R pushBatch(@Parameter(description = "业务类型", required = true) @PathVariable String bizType) {
+ String result = switch (bizType) {
+ case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsPushTask.pushUnsyncedProjects();
+ case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsPushTask.pushUnsyncedContracts();
+ case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPushTask.pushUnsyncedPayments();
+ case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsPushTask.pushUnsyncedInvoiceApplications();
+ case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsPushTask.pushUnsyncedInvoiceReceipts();
+ default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType);
+ };
+ return R.data(result);
+ }
+
+ /**
+ * 页面勾选批量重推(项目立项/合同列表勾选行后批量推送,逐条推送互不影响,单条失败计入失败明细)
+ */
+ @PostMapping("/sync/{bizType}/batch")
+ @ApiOperationSupport(order = 17)
+ @Operation(summary = "按勾选ID批量推送/重推合规", description = "仅支持 project(项目立项)、contract(合同),逐条重置待同步后立即推送,单条失败不影响其余,返回成功数与失败明细")
+ public R repushBatch(@Parameter(description = "业务类型 project/contract", required = true) @PathVariable String bizType,
+ @Parameter(description = "单据ID列表", required = true) @RequestBody List ids) {
+ if (!List.of(HcsSyncConstant.BIZ_TYPE_PROJECT, HcsSyncConstant.BIZ_TYPE_CONTRACT).contains(bizType)) {
+ throw new IllegalArgumentException("不支持的HCS批量推送业务类型:" + bizType);
+ }
+ if (ids == null || ids.isEmpty()) {
+ return R.fail("请先勾选要推送的单据");
+ }
+ int successCount = 0;
+ List failureList = new ArrayList<>();
+ for (Long id : ids) {
+ try {
+ if (HcsSyncConstant.BIZ_TYPE_PROJECT.equals(bizType)) {
+ hcsProjectSyncSupport.repushProject(id);
+ } else {
+ hcsContractSyncSupport.repushContract(id);
+ }
+ successCount++;
+ } catch (Exception exception) {
+ failureList.add(id + ":" + exception.getMessage());
+ }
+ }
+ String summary = "批量推送完成:成功 " + successCount + " 条,失败 " + failureList.size() + " 条";
+ if (!failureList.isEmpty()) {
+ summary += ";失败明细:" + String.join(";", failureList);
+ }
+ return R.data(summary);
+ }
+
+ /**
+ * 特批结果人工确认(§9-U1 过渡兜底:与合规 paymentLegal/result 回调等效,均联动付款审批状态)
+ */
+ @PostMapping("/spec-pay/confirm")
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "特批结果人工确认", description = "传入付款申请ID与结果(approved/rejected),仅特批中(processing)允许确认;approved 恢复审批通过放行,rejected 联动付款审批驳回")
+ public R confirmSpecResult(@Parameter(description = "付款申请ID", required = true) @RequestParam Long id,
+ @Parameter(description = "确认结果 approved/rejected", required = true) @RequestParam String result) {
+ return R.data(hcsPaymentSyncSupport.confirmSpecResult(id, result));
+ }
+
+ /**
+ * 刷新主数据缓存(C103-6 模板 / C103-7 分类 / C103-12 币种)
+ */
+ @PostMapping("/master-data/refresh")
+ @ApiOperationSupport(order = 4)
+ @Operation(summary = "刷新HCS主数据缓存", description = "全量重拉模板/分类/币种列表并缓存,同时校验模板映射配置")
+ public R refreshMasterData() {
+ return R.data(hcsMasterDataSupport.refreshAll());
+ }
+
+ /**
+ * 合规模板列表(C103-6,Redis 缓存优先,前端接入设计 §3.1)
+ */
+ @GetMapping("/master-data/template")
+ @ApiOperationSupport(order = 9)
+ @Operation(summary = "合规模板列表查询", description = "C103-6 模板列表,走服务端 Redis 缓存,未命中自动全量拉取")
+ public R> templates() {
+ return R.data(hcsMasterDataSupport.getTemplates());
+ }
+
+ /**
+ * 合规币种列表(C103-12,Redis 缓存优先,前端接入设计 §3.1)
+ */
+ @GetMapping("/master-data/currency")
+ @ApiOperationSupport(order = 16)
+ @Operation(summary = "合规币种列表查询", description = "C103-12 币种列表,走服务端 Redis 缓存,未命中自动全量拉取")
+ public R> currencies() {
+ return R.data(hcsMasterDataSupport.getCurrencies());
+ }
+
+ /**
+ * 客商风险等级查询(C101-1,展示用,前端接入设计 §3.3;强制校验仍在推送/付款链路):
+ * 内部组织类型客商默认无风险不调合规,外部客商高风险/黑名单返回提示文案
+ */
+ @GetMapping("/risk")
+ @ApiOperationSupport(order = 10)
+ @Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕")
+ public R risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode) {
+ return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode));
+ }
+
+ /**
+ * 客商风险批量查询(C101-1,客商列表页展示用,前端接入设计 §3.3):
+ * 按客商编号集合逐个查询(口径同 /risk),展示用不作为安全边界,单个失败跳过不阻断整页
+ */
+ @GetMapping("/risk/batch")
+ @ApiOperationSupport(order = 13)
+ @Operation(summary = "客商风险批量查询", description = "C101-1 按客商编号集合批量查询风险等级与黑名单状态(展示用),口径同单个查询,单个失败跳过不阻断")
+ public R> riskBatch(@Parameter(description = "客商编号集合,逗号分隔", required = true) @RequestParam String customerCodes) {
+ return R.data(hcsCreditCheckSupport.queryCustomerRiskBatch(Func.toStrList(customerCodes)));
+ }
+
+ /**
+ * 客商风险按名称查询(C101-1,客商档案表单展示用,前端接入设计 §3.3):
+ * 按名称反查客商编号后口径同 /risk;反查不到客商编号不阻断(risky=false,§9-U6)
+ */
+ @GetMapping("/risk/by-name")
+ @ApiOperationSupport(order = 14)
+ @Operation(summary = "客商风险按名称查询", description = "C101-1 按客商名称查询风险(展示用),名称反查编号后口径同编号查询,反查不到默认无风险")
+ public R riskByName(@Parameter(description = "客商名称", required = true) @RequestParam String customerName) {
+ return R.data(hcsCreditCheckSupport.queryCustomerRiskByName(customerName));
+ }
+
+ /**
+ * 附件上传到合同系统(C103-2 代理,前端接入设计 §3.2),返回合规附件ID
+ */
+ @PostMapping(value = "/attachment/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @ApiOperationSupport(order = 11)
+ @Operation(summary = "附件上传到合同系统", description = "C103-2 代理:单文件上传至合规,返回合规附件ID(fdId)")
+ public R uploadAttachment(@Parameter(description = "附件文件", required = true) @RequestParam("fdFile") MultipartFile fdFile) {
+ return R.data(hcsAttachmentSupport.uploadOne(fdFile, AuthUtil.getUserAccount()));
+ }
+
+ /**
+ * 合同附件下载(C103-5 代理,前端接入设计 §3.2):文件流透传浏览器
+ */
+ @GetMapping("/attachment/download")
+ @ApiOperationSupport(order = 12)
+ @Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名")
+ public void downloadAttachment(@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId,
+ @Parameter(description = "下载文件名(可选)") @RequestParam(required = false) String name,
+ HttpServletResponse response) throws Exception {
+ String fileName = StringUtil.isBlank(name) ? "hcs-attachment-" + fdId : name;
+ response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+ response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
+ try (Response hcsResponse = hcsAttachmentSupport.downloadRaw(fdId);
+ InputStream inputStream = hcsResponse.body().asInputStream();
+ OutputStream outputStream = response.getOutputStream()) {
+ inputStream.transferTo(outputStream);
+ outputStream.flush();
+ }
+ }
+
+ /**
+ * 合同特批结果人工确认(§6-U1 过渡方案:与合规推送等效)
+ */
+ @PostMapping("/contract/spec/confirm")
+ @ApiOperationSupport(order = 14)
+ @Operation(summary = "合同特批结果人工确认", description = "传入合同ID 与结果(approved/rejected),仅特批中(processing)允许确认")
+ public R confirmContractSpec(@Parameter(description = "合同ID", required = true) @RequestParam Long id,
+ @Parameter(description = "确认结果 approved/rejected", required = true) @RequestParam String result) {
+ return R.data(hcsContractSpecSyncSupport.confirmSpecResult(id, result));
+ }
+
+ /**
+ * 合同特批结果回传接收(合规系统 contractLegal/result 推送,替代未建设的 D103,原文留痕)
+ *
+ * 免登免授权:blade.secure skip-url + auth permitAll 放行(doc/nacos/blade.yaml);
+ * iPaaS 网关注册映射:/gwzh/HTHG/contractLegal/result → /hcs/contractLegal/result;
+ * 报文 {sourceSystem, requestId=合规合同ID(hcs_fd_id), id, status=APPROVED/REJECTED, reason},
+ * 按 requestId=hcs_fd_id 定位合同,approved 恢复流程展示,rejected 联动合同审批驳回。
+ */
+ @PostMapping("/contractLegal/result")
+ @ApiOperationSupport(order = 15)
+ @Operation(summary = "合同特批结果回传接收", description = "合规系统推送合同特批结果,按合规合同ID(hcs_fd_id=requestId)定位并联动特批状态,原文留痕")
+ public R contractLegalResult(@RequestBody(required = false) HcsLegalResultDTO param) {
+ return R.data(hcsContractSpecSyncSupport.handleLegalResult(param));
+ }
+
+ /**
+ * 付款特批结果回传接收(合规系统 paymentLegal/result 推送)
+ *
+ * 免登免授权同合同接口(blade.secure 放行);
+ * iPaaS 网关注册映射:/gwzh/HTHG/paymentLegal/result → /hcs/paymentLegal/result;
+ * 报文 {sourceSystem, requestId=TMS付款申请ID, status=APPROVED/REJECTED, reason},
+ * approved 恢复"审批通过"放行,rejected 联动付款审批驳回走重新提交闭环。
+ */
+ @PostMapping("/paymentLegal/result")
+ @ApiOperationSupport(order = 16)
+ @Operation(summary = "付款特批结果回传接收", description = "合规系统推送付款特批结果,按付款申请ID定位并联动特批状态与付款审批状态,原文留痕")
+ public R paymentLegalResult(@RequestBody(required = false) HcsLegalResultDTO param) {
+ return R.data(hcsPaymentSyncSupport.handleLegalResult(param));
+ }
+
+ /**
+ * 项目金蝶同步结果回传接收(合规系统 projectLegal/result 推送,D902,原文留痕)
+ *
+ * 免登免授权:blade.secure skip-url + auth permitAll 放行(doc/nacos/blade.yaml);
+ * iPaaS 网关注册映射:/gwzh/HTHG/projectLegal/result → /hcs/projectLegal/result;
+ * 报文 {sourceSystem, projectCode, requestId, success, kingdeeId, projectNumber, reason},
+ * 按 projectCode=TMS 项目编号定位,success=true 回填金蝶项目ID/编号,success=false 记录失败原因。
+ */
+ @PostMapping("/projectLegal/result")
+ @ApiOperationSupport(order = 18)
+ @Operation(summary = "项目金蝶同步结果回传接收", description = "合规系统推送项目金蝶同步结果(D902),按项目编号定位并回填金蝶ID,原文留痕")
+ public R projectLegalResult(@RequestBody(required = false) HcsProjectLegalResultDTO param) {
+ return R.data(hcsProjectSyncSupport.handleLegalResult(param));
+ }
+
+ /**
+ * 合同金蝶同步结果回传接收(合规系统 contractKingdee/result 推送,D903,投送人/钢厂/结算中心合同共用,原文留痕)
+ *
+ * 免登免授权:blade.secure skip-url + auth permitAll 放行(doc/nacos/blade.yaml);
+ * iPaaS 网关注册映射:/gwzh/HTHG/contractKingdee/result → /hcs/contractKingdee/result;
+ * 报文 {contractType?, contractCode, requestId, success, reason?, kingdeeId?, sourceSystem?},
+ * 本接口不处理法务审批结论,仅按 contractCode=TMS 合同编号定位回填金蝶合同ID/记录失败原因。
+ */
+ @PostMapping("/contractKingdee/result")
+ @ApiOperationSupport(order = 19)
+ @Operation(summary = "合同金蝶同步结果回传接收", description = "合规系统推送合同金蝶最终同步结果(D903,投送人/钢厂/结算中心共用),按合同编号定位回填金蝶ID,不处理法务审批结论,原文留痕")
+ public R contractKingdeeResult(@RequestBody(required = false) HcsContractLegalResultDTO param) {
+ return R.data(hcsContractSyncSupport.handleKingdeeResult(param));
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
index bbe0bcf3..34c74ec3 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
@@ -134,12 +134,16 @@ public class InvoiceApplicationController extends BladeController {
return approveResponse();
}
- /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
+ /** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
+ String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
+ if (syncResult != null) {
+ return R.success("审批通过,已同步金蝶:" + syncResult);
+ }
return R.success("审批通过");
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java
index 59eb30b6..c9023ceb 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java
@@ -150,12 +150,16 @@ public class InvoiceReceiptController extends BladeController {
return approveResponse();
}
- /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
+ /** 审批接口统一响应:审批已生效,自动推送结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
+ String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
+ if (syncResult != null) {
+ return R.success("审批通过,已同步金蝶:" + syncResult);
+ }
return R.success("审批通过");
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java
index ae6f6796..149f908e 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java
@@ -29,13 +29,16 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
+import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest;
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
+import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import org.springblade.transport.service.IPaymentApplicationService;
+import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -43,6 +46,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import java.util.Arrays;
import java.util.List;
/** 付款申请控制器。 @author Chill */
@@ -53,6 +57,7 @@ import java.util.List;
@Tag(name = "付款管理", description = "付款申请管理")
public class PaymentApplicationController extends BladeController {
private final IPaymentApplicationService paymentApplicationService;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@GetMapping("/list")
@ApiOperationSupport(order = 1)
@Operation(summary = "付款申请分页")
@@ -96,15 +101,33 @@ public class PaymentApplicationController extends BladeController {
@Operation(summary = "审批通过付款申请")
public R approve(@RequestBody PaymentApplicationStatusRequest request) {
paymentApplicationService.approve(request);
- return approveResponse();
+ // 审批生效后立即发起付款特批(C106-3,不受即时推送开关控制);失败仅告警不阻断审批结果,保留重推
+ hcsImmediatePushSupport.pushPaymentAndWait(request.getId());
+ return approveResponse(request.getId());
}
- /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
- private R approveResponse() {
+ /** 审批接口统一响应:审批已生效,付款特批发起/自动同步结果(成功单据号/失败原因)一并返回;失败用 data=false 供前端区分提示样式 */
+ private R approveResponse(Long id) {
+ String hcsFailure = HcsImmediatePushSupport.pollSyncFailure();
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
+ String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
+ if (hcsFailure != null) {
+ return R.data(Boolean.FALSE, "审批通过,但发起付款特批/推送合规失败:" + hcsFailure + ",可稍后重推合规");
+ }
+ PaymentApplication payment = paymentApplicationService.getById(id);
+ String specStatus = payment == null ? null : payment.getHcsSpecStatus();
+ if (HcsSyncConstant.SPEC_STATUS_NEED.equals(specStatus)) {
+ return R.success("审批通过,付款特批待发起,可在详情页重推合规");
+ }
+ if (Arrays.asList(HcsSyncConstant.SPEC_STATUS_PUSHING, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(specStatus)) {
+ return R.success("审批通过,已发起付款特批,特批通过后将自动推送履约数据并同步金蝶");
+ }
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
+ if (syncResult != null) {
+ return R.success("审批通过,已同步金蝶:" + syncResult);
+ }
return R.success("审批通过");
}
@PostMapping("/return")
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectApplyController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectApplyController.java
index 29b3962c..83b96682 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectApplyController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProjectApplyController.java
@@ -40,6 +40,7 @@ import org.springblade.transport.excel.ProjectApplyExcel;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.vo.ProjectApplyVO;
import org.springblade.transport.service.IProjectApplyService;
+import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -63,6 +64,7 @@ import java.util.Map;
public class ProjectApplyController extends BladeController {
private final IProjectApplyService projectApplyService;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@GetMapping("/detail")
@ApiOperationSupport(order = 1)
@@ -119,7 +121,14 @@ public class ProjectApplyController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "审批通过", description = "传入id")
public R approve(@Parameter(description = "主键", required = true) @RequestParam Long id) {
- return R.status(projectApplyService.approve(id));
+ projectApplyService.approve(id);
+ // 审批事务已提交,再同步推送合规;失败原因以警告文案返回(data=false 供前端区分提示样式),照正式结算单审批接口
+ hcsImmediatePushSupport.pushProjectAndWait(id);
+ String syncFailure = HcsImmediatePushSupport.pollSyncFailure();
+ if (syncFailure != null) {
+ return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure);
+ }
+ return R.success("审批通过");
}
@PostMapping("/reject")
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java
index d03d8d1d..86772411 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementAdjustmentController.java
@@ -39,12 +39,16 @@ public class SettlementAdjustmentController extends BladeController {
String hint = service.approve(request);
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
+ String syncResult = KingdeeApprovalSyncListener.pollSyncResult();
if (Func.isNotBlank(syncFailure)) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (Func.isNotBlank(hint)) {
return R.data(Boolean.TRUE, "审批通过;" + hint);
}
+ if (Func.isNotBlank(syncResult)) {
+ return R.success("审批通过,已同步金蝶:" + syncResult);
+ }
return R.success("审批通过");
}
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java
index 185a0c56..fbecca86 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java
@@ -22,7 +22,9 @@ package org.springblade.transport.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.config.BfiProperties;
+import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
+import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IInvoiceReceiptService;
@@ -66,6 +68,9 @@ public class KingdeeApprovalSyncListener {
/** 当前审批请求线程的自动推送失败原因,供 Controller 取走提示前端(见类注释线程说明) */
private static final ThreadLocal SYNC_FAILURE = new ThreadLocal<>();
+ /** 当前审批请求线程的自动推送成功结果(金蝶单据号/推送摘要),供 Controller 拼进提示文案 */
+ private static final ThreadLocal SYNC_RESULT = new ThreadLocal<>();
+
/**
* 取走并清除当前审批请求的自动推送失败原因;null 表示推送成功或未触发推送。
* 须在 service.approve 返回后立即调用(监听器在审批事务提交时同步执行完毕)。
@@ -76,6 +81,23 @@ public class KingdeeApprovalSyncListener {
return failure;
}
+ /**
+ * 取走并清除当前审批请求的自动推送成功结果;null 表示推送失败或未触发推送。
+ * 与 {@link #pollSyncFailure()} 成对调用,避免线程复用导致结果串单。
+ */
+ public static String pollSyncResult() {
+ String result = SYNC_RESULT.get();
+ SYNC_RESULT.remove();
+ return result;
+ }
+
+ /** 记录本次自动推送的成功结果,空值不记录(未触发推送/接口无返回) */
+ private static void recordResult(String result) {
+ if (result != null && !result.isBlank()) {
+ SYNC_RESULT.set(result);
+ }
+ }
+
/**
* 事务提交后同步推送。fallbackExecution=true 兜底:approve 未开启事务时事件不再被默认丢弃。
*
@@ -84,6 +106,7 @@ public class KingdeeApprovalSyncListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onBillApproved(KingdeeBillApprovedEvent event) {
SYNC_FAILURE.remove();
+ SYNC_RESULT.remove();
try {
switch (event.bizType()) {
case KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT -> pushSettlement(event.id());
@@ -95,6 +118,7 @@ public class KingdeeApprovalSyncListener {
} catch (Exception exception) {
// 推送失败只记日志(失败状态已在推送方法内独立事务落库),不影响审批结果,交给重推任务与手动按钮兜底;
// 原因暂存线程变量,由审批 Controller 取走提示前端
+ SYNC_RESULT.remove();
log.error("审批通过自动同步金蝶失败 bizType={} id={}", event.bizType(), event.id(), exception);
String reason = exception.getMessage() == null ? "金蝶接口调用异常" : exception.getMessage();
// 手动按钮同文案已带"推送金蝶失败:"前缀,拼提示时去掉避免重复
@@ -108,6 +132,7 @@ public class KingdeeApprovalSyncListener {
}
log.info("正式结算单[{}]审批通过自动同步金蝶开始", id);
String billNo = formalSettlementService.syncKingdee(id);
+ recordResult(billNo);
log.info("正式结算单[{}]审批通过自动同步金蝶完成:billNo={}", id, billNo);
}
@@ -115,8 +140,18 @@ public class KingdeeApprovalSyncListener {
if (!bfiProperties.getAutoSync().isPayment()) {
return;
}
+ // 付款特批未完成(高风险/黑名单/额度超额)时金蝶必然拒推:跳过而非置失败,
+ // 特批通过后由 HcsPaymentSyncSupport 重新发布本事件自动补推
+ PaymentApplication payment = paymentApplicationService.getById(id);
+ if (payment != null && payment.getHcsSpecStatus() != null
+ && List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PUSHING,
+ HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(payment.getHcsSpecStatus())) {
+ log.info("付款申请[{}]审批通过自动同步金蝶跳过(付款特批未完成):hcsSpecStatus={}", id, payment.getHcsSpecStatus());
+ return;
+ }
log.info("付款申请[{}]审批通过自动同步金蝶开始", id);
String kingdeeBillId = paymentApplicationService.syncKingdee(id);
+ recordResult(kingdeeBillId);
log.info("付款申请[{}]审批通过自动同步金蝶完成:kingdeeBillId={}", id, kingdeeBillId);
}
@@ -126,6 +161,7 @@ public class KingdeeApprovalSyncListener {
}
log.info("开票申请[{}]审批通过自动同步金蝶开始", id);
String billNo = invoiceApplicationService.syncKingdee(id);
+ recordResult(billNo);
log.info("开票申请[{}]审批通过自动同步金蝶完成:billNo={}", id, billNo);
}
@@ -135,6 +171,7 @@ public class KingdeeApprovalSyncListener {
}
log.info("收票登记[{}]审批通过自动推送金蝶收票单开始", id);
String summary = invoiceReceiptService.pushKingdeeReceivedInvoice(List.of(id), true);
+ recordResult(summary);
log.info("收票登记[{}]审批通过自动推送金蝶收票单完成:{}", id, summary);
}
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsAttachmentSyncMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsAttachmentSyncMapper.java
new file mode 100644
index 00000000..637862ed
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsAttachmentSyncMapper.java
@@ -0,0 +1,39 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.springblade.transport.pojo.entity.HcsAttachmentSync;
+
+/**
+ * HCS 附件转传映射 Mapper
+ *
+ * @author Chill
+ */
+@Mapper
+public interface HcsAttachmentSyncMapper extends BaseMapper {
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractCheckLogMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractCheckLogMapper.java
new file mode 100644
index 00000000..74bca5c8
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractCheckLogMapper.java
@@ -0,0 +1,40 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.springblade.transport.pojo.entity.HcsContractCheckLog;
+
+/**
+ * HCS 风险/额度校验留痕 Mapper
+ *
+ * @author Chill
+ */
+@Mapper
+public interface HcsContractCheckLogMapper extends BaseMapper {
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractExeStageMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractExeStageMapper.java
new file mode 100644
index 00000000..c2021347
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/HcsContractExeStageMapper.java
@@ -0,0 +1,53 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
+
+import java.util.List;
+
+/**
+ * 合同履约规则 Mapper
+ *
+ * @author Chill
+ */
+@Mapper
+public interface HcsContractExeStageMapper extends BaseMapper {
+
+ /**
+ * 查询合同的待同步行(含软删行:C103-9 的 delete 行已逻辑删除但须随行级同步上报,
+ * 原生 SQL 绕过 @TableLogic 过滤)
+ *
+ * @param contractId 合同ID
+ * @return 行级同步操作非 none 的规则行
+ */
+ @Select("select * from blade_contract_exe_stage where contract_id = #{contractId} and row_op <> 'none' order by id")
+ List selectSyncRows(@Param("contractId") Long contractId);
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationSettlementMapper.java
index 78e02855..29d34fa0 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationSettlementMapper.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationSettlementMapper.java
@@ -1,10 +1,21 @@
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
/** 付款申请关联正式结算单 Mapper。 */
@Mapper
public interface PaymentApplicationSettlementMapper extends BaseMapper {
+
+ /**
+ * 按付款申请主键物理删除关联记录(表存在不含逻辑删除字段的关系唯一索引,重存前必须物理清理)
+ *
+ * @param paymentApplicationId 付款申请主键
+ * @return 删除行数
+ */
+ @Delete("DELETE FROM blade_payment_application_settlement WHERE payment_application_id = #{paymentApplicationId}")
+ int deleteByPaymentApplicationId(@Param("paymentApplicationId") Long paymentApplicationId);
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
index b96c8469..668d232a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
@@ -25,7 +25,10 @@ import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springblade.transport.service.IPreSettlementService;
+import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.service.IProjectApplyService;
+import org.springblade.transport.service.IWaybillService;
+import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@@ -75,6 +78,7 @@ public final class MkProcessBizHandlers {
@RequiredArgsConstructor
public static class ProjectHandler implements IMkProcessBizHandler {
private final IProjectApplyService service;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@Override
public String bizType() {
@@ -122,7 +126,13 @@ public final class MkProcessBizHandlers {
entity.setCurrentNode("审核通过");
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
entity.setApprovedTime(LocalDateTime.now());
- return service.updateById(entity);
+ // HCS 推送:审批通过置待同步状态,更新成功后即时推送,扫描任务保留兜底(ADR-0001 修订,前端接入设计 §6)
+ entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ boolean updated = service.updateById(entity);
+ if (updated) {
+ hcsImmediatePushSupport.pushProjectAfterCommit(id);
+ }
+ return updated;
}
@Override
@@ -145,6 +155,7 @@ public final class MkProcessBizHandlers {
@RequiredArgsConstructor
public static class ContractHandler implements IMkProcessBizHandler {
private final IContractManageService service;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@Override
public String bizType() {
@@ -190,7 +201,13 @@ public final class MkProcessBizHandlers {
entity.setApprovalStatus(change ? "change_approved" : "approved");
entity.setCurrentNode("审核通过");
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
- return service.updateById(entity);
+ // HCS 推送:审批通过置待同步状态,更新成功后即时推送,扫描任务保留兜底(ADR-0001 修订,前端接入设计 §6)
+ entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ boolean updated = service.updateById(entity);
+ if (updated) {
+ hcsImmediatePushSupport.pushContractAfterCommit(id);
+ }
+ return updated;
}
@Override
@@ -361,6 +378,7 @@ public final class MkProcessBizHandlers {
@RequiredArgsConstructor
public static class PaymentHandler implements IMkProcessBizHandler {
private final IPaymentApplicationService service;
+ private final HcsImmediatePushSupport hcsImmediatePushSupport;
@Override
public String bizType() {
@@ -408,8 +426,12 @@ public final class MkProcessBizHandlers {
if (updated != null) {
updated.setCurrentNode("审核通过");
updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
+ // HCS 推送零埋点:MK 审批回调仅置待同步状态,与 /approve 入口收敛于同一权威状态(ADR-0001)
+ updated.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
service.updateById(updated);
}
+ // 审批通过立即发起付款特批(C106-3,不受即时推送开关控制;事务提交后异步执行,失败留待重推兜底)
+ hcsImmediatePushSupport.pushPaymentAfterCommit(id);
return true;
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractExeStageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractExeStageService.java
new file mode 100644
index 00000000..6c8937b7
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractExeStageService.java
@@ -0,0 +1,112 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service;
+
+import org.springblade.core.mp.base.BaseService;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
+import org.springblade.transport.pojo.entity.PaymentApplication;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 合同履约规则 服务接口
+ *
+ * @author Chill
+ */
+public interface IContractExeStageService extends BaseService {
+
+ /**
+ * 按合同查询履约规则行(表单维护用,不含软删行)
+ *
+ * @param contractId 合同ID
+ * @return 规则行列表
+ */
+ List listByContractId(Long contractId);
+
+ /**
+ * 匹配合同的付款履约计划行(已同步,优先按款项性质精确匹配,无则取首条付款行兜底)
+ *
+ * @param contractId 合同ID
+ * @param fundNature 款项性质(progress/prepayment 等,空则直接取首条付款行)
+ * @return 付款履约计划行,无已同步付款行时返回 null
+ */
+ HcsContractExeStage findPaymentStageRow(Long contractId, String fundNature);
+
+ /**
+ * 匹配合同的收款履约计划行(已同步,优先按款项性质精确匹配,无则取首条收款行兜底)
+ *
+ * @param contractId 合同ID
+ * @param fundNature 款项性质(progress/prepayment 等,空则直接取首条收款行)
+ * @return 收款履约计划行,无已同步收款行时返回 null
+ */
+ HcsContractExeStage findCollectionStageRow(Long contractId, String fundNature);
+
+ /**
+ * 金蝶认领推送完成后累加收款履约计划行款项金额(认领金额累加,多次认领累加更新):
+ * 原子累加 fund_amount;已同步行置 row_op=update 凭已存合规阶段ID 随 C103-9 重推,
+ * 未同步行保持 add 待合同链路首推(首推即携带累加后金额)
+ *
+ * @param contractId 合同ID
+ * @param claimedAmount 本次累加的认领金额(须大于0)
+ */
+ void accumulateClaimedAmountOnPush(Long contractId, BigDecimal claimedAmount);
+
+ /**
+ * 付款审批通过累加履约计划行款项金额(款项金额=申请付款金额,多次付款累加更新):
+ * 按款项性质匹配付款计划行,原子累加 fund_amount;已同步行置 row_op=update 凭已存合规阶段ID 随 C103-9 重推,
+ * 未同步行保持 add 待合同链路首推(首推即携带累加后金额)
+ *
+ * @param payment 付款申请单(审批通过)
+ */
+ void accumulatePaidAmountOnApprove(PaymentApplication payment);
+
+ /**
+ * 审批通过自动创建默认履约计划行(合同下无规则行时):承运商合同=付款进度款(收票规则取合同结算模式),
+ * 客户合同=收款进度款;金额/资金日期留空,随合同链路推 C103-9
+ *
+ * @param contractManage 合同单(审批通过)
+ */
+ void createDefaultRowOnApprove(ContractManage contractManage);
+
+ /**
+ * 保存合同履约规则行(按行差异置同步标记:新行/未同步行 add,已同步行 update)
+ *
+ * @param contractId 合同ID
+ * @param rows 规则行
+ * @return 保存结果
+ */
+ boolean submitRows(Long contractId, List rows);
+
+ /**
+ * 删除履约规则行(软删+标记:已同步行置 row_op=delete 待 C103-9 上报;未同步行直接软删)
+ *
+ * @param id 规则行ID
+ * @return 删除结果
+ */
+ boolean removeRow(Long id);
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractExeStageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractExeStageServiceImpl.java
new file mode 100644
index 00000000..b6ce2270
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractExeStageServiceImpl.java
@@ -0,0 +1,302 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+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.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.HcsContractExeStageMapper;
+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.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 合同履约规则 服务实现类(设计文档 §4.1,ADR-0002)
+ *
+ * 行级差异标记:新行/未同步行 add;已同步行编辑 update;已同步行删除 delete(软删+标记,不可物理删除)。
+ * 行级同步由扫描任务随合同链路调 C103-9 完成(HcsExeStageSyncSupport)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ContractExeStageServiceImpl extends BaseServiceImpl implements IContractExeStageService {
+
+ private final ContractManageMapper contractManageMapper;
+ private final HcsProperties hcsProperties;
+
+ @Override
+ public List listByContractId(Long contractId) {
+ return list(Wrappers.lambdaQuery()
+ .eq(HcsContractExeStage::getContractId, contractId)
+ .orderByAsc(HcsContractExeStage::getId));
+ }
+
+ @Override
+ public HcsContractExeStage findPaymentStageRow(Long contractId, String fundNature) {
+ if (contractId == null) {
+ return null;
+ }
+ if (StringUtil.isNotBlank(fundNature)) {
+ HcsContractExeStage natureRow = getOne(Wrappers.lambdaQuery()
+ .eq(HcsContractExeStage::getContractId, contractId)
+ .eq(HcsContractExeStage::getPerformType, HcsSyncConstant.PERFORM_TYPE_PAYMENT)
+ .eq(HcsContractExeStage::getFundNature, fundNature)
+ .isNotNull(HcsContractExeStage::getHcsStageFdId)
+ .orderByAsc(HcsContractExeStage::getId)
+ .last("limit 1"), false);
+ if (natureRow != null) {
+ return natureRow;
+ }
+ }
+ // 兜底:无该款项性质已同步行时取首条已同步付款行(与 C108 履约记录历史口径一致)
+ return getOne(Wrappers.lambdaQuery()
+ .eq(HcsContractExeStage::getContractId, contractId)
+ .eq(HcsContractExeStage::getPerformType, HcsSyncConstant.PERFORM_TYPE_PAYMENT)
+ .isNotNull(HcsContractExeStage::getHcsStageFdId)
+ .orderByAsc(HcsContractExeStage::getId)
+ .last("limit 1"), false);
+ }
+
+ @Override
+ public void accumulatePaidAmountOnApprove(PaymentApplication payment) {
+ if (payment == null || payment.getContractId() == null || payment.getAppliedAmount() == null) {
+ return;
+ }
+ String fundNature = HcsSyncConstant.fundNatureOfPayment(payment.getPaymentType());
+ HcsContractExeStage stageRow = this.findPaymentStageRow(payment.getContractId(), fundNature);
+ // 仅精确匹配款项性质的计划行累加,兜底行款项性质不一致时不累加(避免预付款金额计入进度款计划)
+ if (stageRow == null || !fundNature.equals(stageRow.getFundNature())) {
+ log.info("付款审批未匹配到款项性质[{}]的履约计划行,跳过款项金额累加:paymentNo={},contractId={}",
+ fundNature, payment.getPaymentNo(), payment.getContractId());
+ return;
+ }
+ // 原子累加防并发付款审批丢失更新(coalesce 兼容多数据库)
+ update(Wrappers.lambdaUpdate()
+ .eq(HcsContractExeStage::getId, stageRow.getId())
+ .setSql("fund_amount = coalesce(fund_amount, 0) + " + payment.getAppliedAmount().toPlainString()));
+ boolean resyncNeeded = StringUtil.isNotBlank(stageRow.getHcsStageFdId());
+ if (resyncNeeded) {
+ // 已同步行置 update 凭已存合规阶段ID 随 C103-9 重推;未同步行保持 add 待合同链路首推
+ update(Wrappers.lambdaUpdate()
+ .eq(HcsContractExeStage::getId, stageRow.getId())
+ .set(HcsContractExeStage::getRowOp, HcsSyncConstant.ROW_OP_UPDATE)
+ .set(HcsContractExeStage::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_UNSYNCED)
+ .set(HcsContractExeStage::getHcsSyncMsg, "付款审批款项金额累加,待重推C103-9"));
+ }
+ log.info("付款审批履约计划款项金额累加:paymentNo={},stageRowId={},累加金额={},rowOp={}",
+ payment.getPaymentNo(), stageRow.getId(), payment.getAppliedAmount(), resyncNeeded ? "update" : "add(待合同链路首推)");
+ }
+
+ @Override
+ public HcsContractExeStage findCollectionStageRow(Long contractId, String fundNature) {
+ if (contractId == null) {
+ return null;
+ }
+ if (StringUtil.isNotBlank(fundNature)) {
+ HcsContractExeStage natureRow = getOne(Wrappers.lambdaQuery()
+ .eq(HcsContractExeStage::getContractId, contractId)
+ .eq(HcsContractExeStage::getPerformType, HcsSyncConstant.PERFORM_TYPE_COLLECTION)
+ .eq(HcsContractExeStage::getFundNature, fundNature)
+ .isNotNull(HcsContractExeStage::getHcsStageFdId)
+ .orderByAsc(HcsContractExeStage::getId)
+ .last("limit 1"), false);
+ if (natureRow != null) {
+ return natureRow;
+ }
+ }
+ // 兜底:无该款项性质已同步行时取首条已同步收款行
+ return getOne(Wrappers.lambdaQuery()
+ .eq(HcsContractExeStage::getContractId, contractId)
+ .eq(HcsContractExeStage::getPerformType, HcsSyncConstant.PERFORM_TYPE_COLLECTION)
+ .isNotNull(HcsContractExeStage::getHcsStageFdId)
+ .orderByAsc(HcsContractExeStage::getId)
+ .last("limit 1"), false);
+ }
+
+ @Override
+ public void accumulateClaimedAmountOnPush(Long contractId, BigDecimal claimedAmount) {
+ if (contractId == null || claimedAmount == null || claimedAmount.signum() <= 0) {
+ return;
+ }
+ HcsContractExeStage stageRow = this.findCollectionStageRow(contractId, HcsSyncConstant.FUND_NATURE_PROGRESS);
+ if (stageRow == null) {
+ log.info("收款认领未匹配到已同步收款履约计划行,跳过款项金额累加:contractId={}", contractId);
+ return;
+ }
+ // 原子累加防并发认领推送丢失更新(coalesce 兼容多数据库)
+ update(Wrappers.lambdaUpdate()
+ .eq(HcsContractExeStage::getId, stageRow.getId())
+ .setSql("fund_amount = coalesce(fund_amount, 0) + " + claimedAmount.toPlainString()));
+ boolean resyncNeeded = StringUtil.isNotBlank(stageRow.getHcsStageFdId());
+ if (resyncNeeded) {
+ // 已同步行置 update 凭已存合规阶段ID 随 C103-9 重推;未同步行保持 add 待合同链路首推
+ update(Wrappers.lambdaUpdate()
+ .eq(HcsContractExeStage::getId, stageRow.getId())
+ .set(HcsContractExeStage::getRowOp, HcsSyncConstant.ROW_OP_UPDATE)
+ .set(HcsContractExeStage::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_UNSYNCED)
+ .set(HcsContractExeStage::getHcsSyncMsg, "收款认领金额累加,待重推C103-9"));
+ }
+ log.info("收款认领履约计划款项金额累加:contractId={},stageRowId={},累加金额={},rowOp={}",
+ contractId, stageRow.getId(), claimedAmount, resyncNeeded ? "update" : "add(待合同链路首推)");
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean submitRows(Long contractId, List rows) {
+ ContractManage contractManage = contractManageMapper.selectById(contractId);
+ if (contractManage == null) {
+ throw new ServiceException("合同不存在:" + contractId);
+ }
+ if (Func.isEmpty(rows)) {
+ throw new ServiceException("履约规则行不能为空");
+ }
+ for (HcsContractExeStage row : rows) {
+ validateRow(row);
+ row.setContractId(contractId);
+ if (row.getId() == null) {
+ // 新行:待随合同链路首推(row_op=add)
+ row.setRowOp(HcsSyncConstant.ROW_OP_ADD);
+ row.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ row.setHcsSyncMsg("待随合同链路同步");
+ row.setFundType(StringUtil.isBlank(row.getFundType()) ? hcsProperties.getExeStage().getFundType() : row.getFundType());
+ row.setIsFinish(row.getIsFinish() == null ? 0 : row.getIsFinish());
+ save(row);
+ } else {
+ HcsContractExeStage existing = getById(row.getId());
+ if (existing == null || !contractId.equals(existing.getContractId())) {
+ throw new ServiceException("履约规则行不属于该合同:" + row.getId());
+ }
+ // 已同步行编辑 → update;未同步行保持 add
+ String rowOp = StringUtil.isBlank(existing.getHcsStageFdId()) ? HcsSyncConstant.ROW_OP_ADD : HcsSyncConstant.ROW_OP_UPDATE;
+ existing.setPerformType(row.getPerformType());
+ existing.setFundNature(row.getFundNature());
+ existing.setFundAmount(row.getFundAmount());
+ existing.setFundDate(row.getFundDate());
+ existing.setInvoiceRule(row.getInvoiceRule());
+ existing.setInvoiceCycle(row.getInvoiceCycle());
+ existing.setFundType(StringUtil.isBlank(row.getFundType()) ? hcsProperties.getExeStage().getFundType() : row.getFundType());
+ existing.setIsFinish(row.getIsFinish() == null ? 0 : row.getIsFinish());
+ existing.setRowOp(rowOp);
+ existing.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ existing.setHcsSyncMsg("待随合同链路同步(" + rowOp + ")");
+ updateById(existing);
+ }
+ }
+ log.info("合同履约规则保存:contractId={},共 {} 行", contractId, rows.size());
+ return true;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean removeRow(Long id) {
+ HcsContractExeStage row = getById(id);
+ if (row == null) {
+ throw new ServiceException("履约规则行不存在:" + id);
+ }
+ if (StringUtil.isNotBlank(row.getHcsStageFdId())) {
+ // 已同步行:软删+标记 delete,由 C103-9 凭合规阶段ID 上报删除(ADR-0002,不可物理删除)
+ row.setRowOp(HcsSyncConstant.ROW_OP_DELETE);
+ row.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ row.setHcsSyncMsg("待随合同链路同步(delete)");
+ updateById(row);
+ }
+ removeById(id);
+ log.info("合同履约规则行删除:id={},contractId={},rowOp={}", id, row.getContractId(), row.getRowOp());
+ return true;
+ }
+
+ @Override
+ public void createDefaultRowOnApprove(ContractManage contractManage) {
+ Long contractId = contractManage.getId();
+ if (count(Wrappers.lambdaQuery().eq(HcsContractExeStage::getContractId, contractId)) > 0) {
+ return;
+ }
+ HcsContractExeStage row = new HcsContractExeStage();
+ row.setContractId(contractId);
+ if ("承运商合同".equals(contractManage.getContractCategory())) {
+ row.setPerformType(HcsSyncConstant.PERFORM_TYPE_PAYMENT);
+ row.setInvoiceRule(mapInvoiceRule(contractManage.getSettlementMode()));
+ } else if ("客户合同".equals(contractManage.getContractCategory())) {
+ row.setPerformType(HcsSyncConstant.PERFORM_TYPE_COLLECTION);
+ } else {
+ log.info("合同类别非承运商/客户,不自动创建履约计划行:contractNo={},contractCategory={}", contractManage.getContractNo(), contractManage.getContractCategory());
+ return;
+ }
+ // 默认进度款计划行:金额/资金日期留空,随合同链路推 C103-9
+ row.setFundNature(HcsSyncConstant.FUND_NATURE_PROGRESS);
+ row.setFundType(hcsProperties.getExeStage().getFundType());
+ row.setIsFinish(0);
+ row.setRowOp(HcsSyncConstant.ROW_OP_ADD);
+ row.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ row.setHcsSyncMsg("审批通过自动创建,待随合同链路同步");
+ save(row);
+ log.info("审批通过自动创建履约计划行:contractNo={},contractCategory={},performType={},invoiceRule={}",
+ contractManage.getContractNo(), contractManage.getContractCategory(), row.getPerformType(), row.getInvoiceRule());
+ }
+
+ /**
+ * 结算模式文本映射 C103-9 开票规则:先款后票=payFirst、先票后款=invoiceFirst,其余(月结/现结等)不传
+ */
+ private String mapInvoiceRule(String settlementMode) {
+ if ("先款后票".equals(settlementMode)) {
+ return "payFirst";
+ }
+ if ("先票后款".equals(settlementMode)) {
+ return "invoiceFirst";
+ }
+ return null;
+ }
+
+ /**
+ * 行字段校验
+ */
+ private void validateRow(HcsContractExeStage row) {
+ if (StringUtil.isBlank(row.getPerformType())) {
+ throw new ServiceException("履约类型不能为空(payment/collection)");
+ }
+ if (!List.of(HcsSyncConstant.PERFORM_TYPE_PAYMENT, HcsSyncConstant.PERFORM_TYPE_COLLECTION).contains(row.getPerformType())) {
+ throw new ServiceException("履约类型非法:" + row.getPerformType());
+ }
+ if (row.getFundAmount() != null && row.getFundAmount().signum() < 0) {
+ throw new ServiceException("计划金额不能为负数");
+ }
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
index f1a4a920..bb9d57d0 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
@@ -25,6 +25,8 @@ 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 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;
@@ -35,12 +37,17 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
+import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.excel.ContractManageExcel;
import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.HcsContractExeStageMapper;
import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
import org.springblade.transport.pojo.vo.ContractManageVO;
+import org.springblade.transport.service.IContractExeStageService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.support.TransportBusinessSupport;
+import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
import org.springblade.transport.wrapper.ContractManageWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -65,9 +72,15 @@ import java.util.regex.Pattern;
*
* @author Chill
*/
+@Slf4j
@Service
+@RequiredArgsConstructor
public class ContractManageServiceImpl extends BaseServiceImpl implements IContractManageService {
+ private final HcsContractExeStageMapper hcsContractExeStageMapper;
+ private final HcsCreditCheckSupport hcsCreditCheckSupport;
+ private final IContractExeStageService contractExeStageService;
+
private static final String STAGE_DRAFT = "draft";
private static final String STAGE_TEMPORARY = "temporary";
private static final String STAGE_FORMAL = "formal";
@@ -150,6 +163,8 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaUpdate()
+ .eq(HcsContractExeStage::getContractId, contractManage.getId())
+ .isNull(HcsContractExeStage::getHcsStageFdId)
+ .set(HcsContractExeStage::getRowOp, "add"));
return updateById(contractManage);
}
@@ -371,6 +399,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl queryWrapper, Boolean kingdeeSynced, Boolean hcsSynced) {
+ if (Boolean.TRUE.equals(kingdeeSynced)) {
+ queryWrapper.isNotNull(ContractManage::getKingdeeId).ne(ContractManage::getKingdeeId, "");
+ } else if (Boolean.FALSE.equals(kingdeeSynced)) {
+ queryWrapper.and(wrapper -> wrapper.isNull(ContractManage::getKingdeeId)
+ .or().eq(ContractManage::getKingdeeId, ""));
+ }
+ if (Boolean.TRUE.equals(hcsSynced)) {
+ queryWrapper.eq(ContractManage::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_SYNCED);
+ } else if (Boolean.FALSE.equals(hcsSynced)) {
+ queryWrapper.and(wrapper -> wrapper.isNull(ContractManage::getHcsSyncStatus)
+ .or().ne(ContractManage::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_SYNCED));
+ }
+ }
+
private void applyEffectiveType(LambdaQueryWrapper queryWrapper, String effectiveType) {
if (Func.isEmpty(effectiveType)) {
return;
@@ -1205,6 +1258,49 @@ public class ContractManageServiceImpl extends BaseServiceImpl HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION
+ .equals(Objects.toString(attachment.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD), null)));
+ }
+
+ /**
+ * 单方客商是否高风险/黑名单(C101-1 实时查询,名称反查不到客商编号不阻断)
+ */
+ private boolean isRiskyCustomer(String partyName) {
+ if (Func.isEmpty(partyName)) {
+ return false;
+ }
+ return Boolean.TRUE.equals(hcsCreditCheckSupport.queryCustomerRiskByName(partyName).getRisky());
+ }
+
private String mergeAttachmentJson(String existingJson, String pendingJson) {
List merged = parseJsonArray(existingJson);
List pending = parseJsonArray(pendingJson);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java
index ce836642..7b74830e 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java
@@ -52,6 +52,7 @@ 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.HcsSyncConstant;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.KingdeeLockConstant;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
@@ -1271,6 +1272,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl
implements IInvoiceReceiptService {
@@ -2073,6 +2077,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl selectPage(IPage page, PaymentApplicationVO query) {
@@ -460,6 +466,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl settlements,
List lines) {
if (settlements.isEmpty()) return;
- settlementRelationMapper.delete(Wrappers.lambdaQuery()
- .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()));
- boolean customAmounts = this.useSpecifiedLineAmounts(lines);
+ // 关系唯一索引不含 is_deleted,逻辑删除会残留旧行导致重存冲突,此处必须物理删除
+ settlementRelationMapper.deleteByPaymentApplicationId(entity.getId());
+ boolean customAmounts = settlements.size() > 1 && lines != null && !lines.isEmpty();
BigDecimal totalBase = settlements.stream()
.map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java
index d72428c5..cf107fbd 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java
@@ -37,6 +37,7 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
+import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.excel.ProjectApplyExcel;
import org.springblade.transport.mapper.ProjectApplyMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
@@ -213,6 +214,8 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper, Boolean kingdeeSynced, Boolean hcsSynced) {
+ if (Boolean.TRUE.equals(kingdeeSynced)) {
+ queryWrapper.isNotNull(ProjectApply::getKingdeeId).ne(ProjectApply::getKingdeeId, "");
+ } else if (Boolean.FALSE.equals(kingdeeSynced)) {
+ queryWrapper.and(wrapper -> wrapper.isNull(ProjectApply::getKingdeeId)
+ .or().eq(ProjectApply::getKingdeeId, ""));
+ }
+ if (Boolean.TRUE.equals(hcsSynced)) {
+ queryWrapper.eq(ProjectApply::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_SYNCED);
+ } else if (Boolean.FALSE.equals(hcsSynced)) {
+ queryWrapper.and(wrapper -> wrapper.isNull(ProjectApply::getHcsSyncStatus)
+ .or().ne(ProjectApply::getHcsSyncStatus, HcsSyncConstant.HCS_STATUS_SYNCED));
+ }
+ }
+
private void fillFundUseRisk(List records) {
if (records == null || records.isEmpty()) {
return;
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java
index e6c6aecd..899315a4 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java
@@ -65,6 +65,7 @@ import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IReceiptFlowService;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springblade.transport.support.TransportBusinessSupport;
+import org.springblade.transport.support.hcs.HcsClaimSyncSupport;
import org.springblade.transport.wrapper.ReceiptFlowWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -130,6 +131,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl selectPage(IPage page, ReceiptFlowVO query) {
@@ -904,10 +906,15 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl doPushClaimToKingdee(flowId, announce));
+ ReceiptClaimPushResult result = transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce));
+ if (result != null && result.isSuccess()) {
+ hcsClaimSyncSupport.pushClaimToHcs(flowId);
+ }
+ return result;
}
/**
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
index db94c665..84ee99ce 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
@@ -282,6 +282,8 @@ public class KingdeeApBillSyncSupport {
param.setCurrencyNumber(currency);
// 【必填】付款组织.编码
param.setPayorgNumber(orgNumber);
+ // 部门.编码:项目业务部门自身的金蝶编码(结算/付款组织仍传上溯平台公司编码,口径与付款申请 A103-3 一致)
+ param.setDepartmentNumber(this.kingdeeDeptNumber(contract.getProjectId(), context));
// 采购组织.编码(非必传)
// param.setPurorgNumber(orgNumber);
// 【必填】款项性质.编码
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
index 64a3a99a..f9e214c1 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java
@@ -218,6 +218,8 @@ public class KingdeeArBillSyncSupport {
param.setOrgNumber(orgNumber);
// 收款组织.编码:同结算组织
param.setRecorgNumber(orgNumber);
+ // 部门.编码:项目业务部门自身的金蝶编码(结算/收款组织仍传上溯平台公司编码,口径与付款申请/应付单一致)
+ param.setDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context));
// 【必填】单据日期
param.setBizdate(billDateText);
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java
new file mode 100644
index 00000000..07bcfeb0
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java
@@ -0,0 +1,367 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.http.HttpUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.tool.jackson.JsonUtil;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsAttachmentDownloadDTO;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsAttachmentVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.HcsAttachmentSyncMapper;
+import org.springblade.transport.pojo.entity.HcsAttachmentSync;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+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;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 附件转传支撑组件(C103-2 multipart 上传,设计文档 §6)
+ *
+ * 流程:解析单据附件 JSON 元素(去重键照 ContractManage#attachmentKey 惯例)→
+ * 按元数据 URL 从文件服务拉取流(基址可配 attachment.file-download-base)→
+ * C103-2 上传取得合规附件ID → 落映射同步表(blade_hcs_attachment_sync,照金蝶附件同步表设计:
+ * biz_type+biz_id+attachment_key 唯一,转传前查命中即复用,仅落成功记录)。
+ * 任一附件转传失败 → 抛出异常由整单推送失败进补偿重试(失败原因落单据 hcs_sync_msg)。
+ *
+ * 合同链路挂载字段 fdEntityKey(v4 枚举):主文件 approved=true → eopCdraftSign(用印)、
+ * 特批附件(fileType=特批附件)→ eopCdraftSpecial(随 C103-1 fdSepcailContract=true 特批流程)、其它附件 → eopCdraftOther。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsAttachmentSupport {
+
+ /**
+ * 附件元数据去重键候选字段(照 ContractManage#attachmentKey 既有惯例)
+ */
+ private static final List KEY_FIELDS = List.of("url", "link", "fileUrl", "downloadUrl", "uid", "originalName", "name");
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final HcsAttachmentSyncMapper hcsAttachmentSyncMapper;
+
+ /**
+ * 解析单据附件 JSON 并转传,返回合规附件引用列表(保持行序)
+ *
+ * @param bizType 业务单据类型(HcsSyncConstant.BIZ_TYPE_*)
+ * @param bizId 业务单据id
+ * @param attachmentsJson 附件元数据 JSON 数组
+ * @param operatorLoginName 操作人登录名(C103-2 fdLoginName)
+ * @return 合规附件引用列表(fdId + fdEntityKey)
+ */
+ public List transferAttachments(String bizType, Long bizId, String attachmentsJson, String operatorLoginName) {
+ if (StringUtil.isBlank(attachmentsJson)) {
+ return List.of();
+ }
+ List> attachments = parseJsonArray(attachmentsJson);
+ List attachmentRefs = new ArrayList<>();
+ for (Map, ?> attachment : attachments) {
+ attachmentRefs.add(this.transferOne(bizType, bizId, attachment, operatorLoginName));
+ }
+ return attachmentRefs;
+ }
+
+ /**
+ * 解析合同主文件 JSON(仅 approved=true 的文件,挂用印字段)与其它附件 JSON(挂其他附件字段)并转传
+ *
+ * @param bizType 业务单据类型(HcsSyncConstant.BIZ_TYPE_*)
+ * @param bizId 业务单据id
+ * @param contractFileJson 合同主文件 JSON
+ * @param attachmentsJson 其它附件 JSON
+ * @param operatorLoginName 操作人登录名
+ * @return 合规附件引用列表(fdId + fdEntityKey)
+ */
+ public List transferContractAttachments(String bizType, Long bizId, String contractFileJson, String attachmentsJson, String operatorLoginName) {
+ List attachmentRefs = new ArrayList<>();
+ for (Map, ?> file : parseJsonArray(contractFileJson)) {
+ if (isApproved(file)) {
+ attachmentRefs.add(this.transferOne(bizType, bizId, file, operatorLoginName,
+ HcsSyncConstant.CONTRACT_ATTACH_KEY_SEAL));
+ }
+ }
+ for (Map, ?> attachment : parseJsonArray(attachmentsJson)) {
+ // 特批附件(fileType=特批附件)挂特批面板字段,其余挂其他附件字段(2026-10-13:特批改随 C103-1 fdSepcailContract 发起)
+ String entityKey = HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION.equals(attachment.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD))
+ ? HcsSyncConstant.CONTRACT_ATTACH_KEY_SPECIAL : HcsSyncConstant.CONTRACT_ATTACH_KEY_OTHER;
+ attachmentRefs.add(this.transferOne(bizType, bizId, attachment, operatorLoginName, entityKey));
+ }
+ return attachmentRefs;
+ }
+
+ /**
+ * 前端单文件直传合规(C103-2 代理,前端接入设计 §3.2):返回合规附件ID;
+ * 不写映射缓存(无 TMS 附件元数据键),调用方自行持久化 fdId(如特批材料 hcs_spec_files_json)
+ *
+ * @param file 上传文件
+ * @param loginName 操作人登录名(fdLoginName)
+ * @return 合规附件ID
+ */
+ public String uploadOne(MultipartFile file, String loginName) {
+ if (file == null || file.isEmpty()) {
+ throw new HcsException("HCS附件上传失败:文件为空");
+ }
+ HcsResult result = hcsClient.uploadAttachment(file, "false", loginName);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS附件上传失败响应:{}", result == null ? "无响应" : String.format("code=%s, msg=%s, traceId=%s",
+ result.getCode(), result.getMsg(), result.getTraceId()));
+ throw new HcsException("HCS附件上传失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getFdId();
+ }
+
+ /**
+ * 合规附件下载(C103-5 代理,前端接入设计 §3.2):返回原始文件流响应,由控制器透传浏览器
+ *
+ * @param fdId 合规附件ID
+ * @return Feign 文件流响应(调用方负责关闭)
+ */
+ public feign.Response downloadRaw(String fdId) {
+ HcsAttachmentDownloadDTO param = new HcsAttachmentDownloadDTO();
+ // 【必填】附件ID:上传时返回的 fdId
+ param.setFdId(fdId);
+ feign.Response response = hcsClient.downloadAttachment(param);
+ if (response == null || response.body() == null) {
+ throw new HcsException("HCS附件下载失败:响应为空(fdId=" + fdId + ")");
+ }
+ return response;
+ }
+
+ /**
+ * 转传单个附件:映射同步表命中直接复用合规附件ID,未命中下载上传后落成功记录;
+ * 返回引用默认不带 fdEntityKey,由调用方(或合同主文件/其他附件重载)指定挂载字段
+ */
+ private HcsAttachmentRefDTO transferOne(String bizType, Long bizId, Map, ?> attachment, String operatorLoginName) {
+ return this.transferOne(bizType, bizId, attachment, operatorLoginName, null);
+ }
+
+ /**
+ * 转传单个附件并指定挂载字段 key(fdEntityKey,如合同 eopCdraftSpecial/eopCdraftSign/eopCdraftOther)
+ */
+ private HcsAttachmentRefDTO transferOne(String bizType, Long bizId, Map, ?> attachment, String operatorLoginName, String fdEntityKey) {
+ String attachmentKey = attachmentKey(attachment);
+ HcsAttachmentSync existing = hcsAttachmentSyncMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(HcsAttachmentSync::getBizType, bizType)
+ .eq(HcsAttachmentSync::getBizId, bizId)
+ .eq(HcsAttachmentSync::getAttachmentKey, attachmentKey)
+ .last("limit 1"));
+ if (existing != null && StringUtil.isNotBlank(existing.getFdId())) {
+ String url = resolveUrl(attachment);
+ return buildRef(existing.getFdId(), resolveFileName(attachment, url == null ? "" : url), fdEntityKey);
+ }
+ String url = resolveUrl(attachment);
+ if (StringUtil.isBlank(url)) {
+ throw new HcsException("HCS附件转传失败,附件元数据缺少下载地址(attachmentKey=" + attachmentKey + ")");
+ }
+ byte[] fileBytes = downloadFile(url);
+ String fileName = resolveFileName(attachment, url);
+ log.info("HCS附件转传开始:{},大小 {} 字节", fileName, fileBytes.length);
+ HcsResult result = hcsClient.uploadAttachment(
+ new InMemoryMultipartFile("fdFile", fileName, fileBytes), "false", operatorLoginName);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS附件转传失败响应:{}", result == null ? "无响应" : String.format("code=%s, msg=%s, traceId=%s",
+ result.getCode(), result.getMsg(), result.getTraceId()));
+ throw new HcsException("HCS附件转传失败[" + fileName + "]:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ String attachmentFdId = result.getData().getFdId();
+ HcsAttachmentSync record = new HcsAttachmentSync();
+ record.setBizType(bizType);
+ record.setBizId(bizId);
+ record.setAttachmentKey(attachmentKey);
+ record.setAttachmentName(fileName);
+ record.setFdId(attachmentFdId);
+ record.setSyncTime(LocalDateTime.now());
+ hcsAttachmentSyncMapper.insert(record);
+ log.info("HCS附件转传完成:{} → 合规附件ID {}(业务单据[{}:{}],fdEntityKey={})", fileName, attachmentFdId, bizType, bizId, fdEntityKey);
+ return buildRef(attachmentFdId, fileName, fdEntityKey);
+ }
+
+ /**
+ * 合规附件引用(fdId + 挂载字段 key)
+ */
+ private HcsAttachmentRefDTO buildRef(String fdId, String fileName, String fdEntityKey) {
+ HcsAttachmentRefDTO ref = new HcsAttachmentRefDTO();
+ ref.setFdId(fdId);
+ ref.setFdFileName(fileName);
+ ref.setFdEntityKey(fdEntityKey);
+ return ref;
+ }
+
+ /**
+ * 按元数据 URL 从文件服务拉取流:完整 URL 直接下载,相对路径拼可配基址
+ */
+ private byte[] downloadFile(String url) {
+ String target = url;
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ String base = hcsProperties.getAttachment().getFileDownloadBase();
+ if (StringUtil.isBlank(base)) {
+ throw new HcsException("HCS附件转传失败,文件服务下载基址未配置(thirdParty.hcs.attachment.file-download-base)");
+ }
+ target = base + url;
+ }
+ try {
+ return HttpUtil.downloadBytes(target);
+ } catch (Exception exception) {
+ log.error("HCS附件转传下载失败:{}", target, exception);
+ throw new HcsException("HCS附件转传下载失败:" + target, exception);
+ }
+ }
+
+ /**
+ * 附件元数据去重键(url|link|fileUrl|downloadUrl|uid|originalName|name 依次取首个非空)
+ */
+ private String attachmentKey(Map, ?> attachment) {
+ for (String field : KEY_FIELDS) {
+ Object value = attachment.get(field);
+ if (value != null && !String.valueOf(value).isBlank()) {
+ return field + ":" + value;
+ }
+ }
+ return String.valueOf(attachment);
+ }
+
+ /**
+ * 提取附件下载地址
+ */
+ private String resolveUrl(Map, ?> attachment) {
+ for (String field : List.of("url", "link", "fileUrl", "downloadUrl", "path")) {
+ Object value = attachment.get(field);
+ if (value != null && !String.valueOf(value).isBlank()) {
+ return String.valueOf(value).trim();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 提取附件文件名(name/originalName 优先,缺失时取 URL 末段)
+ */
+ private String resolveFileName(Map, ?> attachment, String url) {
+ for (String field : List.of("name", "originalName", "fileName")) {
+ Object value = attachment.get(field);
+ if (value != null && !String.valueOf(value).isBlank()) {
+ return String.valueOf(value);
+ }
+ }
+ String lastSegment = url.substring(url.lastIndexOf('/') + 1);
+ return StringUtil.isBlank(lastSegment) ? "hcs-attachment" : lastSegment;
+ }
+
+ /**
+ * 主文件审批标记判定(照 ContractManage#isAttachmentApproved 惯例)
+ */
+ private boolean isApproved(Map, ?> attachment) {
+ Object approved = attachment.get("approved");
+ if (approved == null) {
+ return false;
+ }
+ return Boolean.TRUE.equals(approved) || Objects.equals(approved, 1)
+ || "1".equals(String.valueOf(approved)) || "true".equalsIgnoreCase(String.valueOf(approved));
+ }
+
+ /**
+ * 解析 JSON 数组(照 ContractManage#parseJsonArray 惯例,异常返回空列表)
+ */
+ private List> parseJsonArray(String json) {
+ if (StringUtil.isBlank(json)) {
+ return List.of();
+ }
+ try {
+ return JsonUtil.parse(json, List.class);
+ } catch (Exception exception) {
+ log.warn("HCS附件元数据解析失败,按无附件处理:{}", json);
+ return List.of();
+ }
+ }
+
+ /**
+ * 内存 MultipartFile(下载字节流转传 C103-2 用)
+ */
+ private record InMemoryMultipartFile(String name, String originalFilename, byte[] bytes) implements MultipartFile {
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getOriginalFilename() {
+ return originalFilename;
+ }
+
+ @Override
+ public String getContentType() {
+ return MediaType.APPLICATION_OCTET_STREAM_VALUE;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return bytes == null || bytes.length == 0;
+ }
+
+ @Override
+ public long getSize() {
+ return bytes == null ? 0 : bytes.length;
+ }
+
+ @Override
+ public byte[] getBytes() {
+ return bytes;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(bytes);
+ }
+
+ @Override
+ public void transferTo(File dest) throws IOException {
+ try (FileOutputStream outputStream = new FileOutputStream(dest)) {
+ outputStream.write(bytes);
+ }
+ }
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java
new file mode 100644
index 00000000..a74391fd
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsClaimSyncSupport.java
@@ -0,0 +1,379 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for their own commercial purposes.
+ *
+ * Using this software signifies agreement to this license agreement and terms of use,
+ * and you expressly agree to be bound by the terms of use of this software.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
+ * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsClaimRecordAddDTO;
+import org.springblade.hcs.dto.HcsCollectionFlowVO;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsIdVO;
+import org.springblade.hcs.dto.HcsMechanismsDTO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO;
+import org.springblade.hcs.dto.HcsPerfRecordPropsDTO;
+import org.springblade.hcs.dto.HcsPerfRecordSaveDTO;
+import org.springblade.hcs.dto.HcsRefVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsUserRefVO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.FormalSettlementMapper;
+import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
+import org.springblade.transport.mapper.ReceiptClaimMapper;
+import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.FormalSettlement;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
+import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
+import org.springblade.transport.pojo.entity.ReceiptClaim;
+import org.springblade.transport.pojo.entity.ReceiptClaimSettlement;
+import org.springblade.transport.service.IContractExeStageService;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * HCS 收款认领链推送编排支撑组件(C103-9 更新履约计划 + C107-1 认领记录 + C108-1 履约流水)
+ *
+ * 触发点:金蝶认领推送(A301-3)成功之后(手动按钮与定时任务共用钩子)。
+ * 开关复用既有配置(不单独新增):exe-stage.sync-enabled 门禁 C103-9/C107-1(与合同链履约计划同步同开关),
+ * perf-record.sync-enabled 门禁 C108-1(与付款链履约记录同步同开关)。
+ * 编排顺序:先 C103-9(认领金额累加到收款履约计划行并重推,C107-1/C108-1 引用其返回的阶段ID),
+ * 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位),最后按合同推 C108-1 履约流水。
+ * 合同未推送合规(hcs_fd_id 空)时仅累加计划行金额,随合同链路首推。
+ * 推送失败仅记日志不回滚金蝶推送结果(单合同失败不影响其余合同)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsClaimSyncSupport {
+
+ /**
+ * 认领完成状态(与 ReceiptFlowServiceImpl.CLAIMED 同值)
+ */
+ private static final String CLAIMED = "claimed";
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final KingdeeReceiptFlowMapper receiptFlowMapper;
+ private final ReceiptClaimMapper claimMapper;
+ private final ReceiptClaimSettlementMapper claimSettlementMapper;
+ private final FormalSettlementMapper formalSettlementMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final IContractExeStageService contractExeStageService;
+ private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
+
+ /**
+ * 金蝶认领推送成功后推送合同合规(C103-9 → C107-1 → C108-1)
+ *
+ * 复用既有开关:exe-stage.sync-enabled(履约计划同步)门禁 C103-9/C107-1,
+ * perf-record.sync-enabled(履约记录同步)门禁 C108-1;开关关闭或推送异常均静默返回,
+ * 不影响金蝶认领推送主流程与事务。
+ *
+ * @param flowId 收款流水ID
+ */
+ public void pushClaimToHcs(Long flowId) {
+ if (!hcsProperties.getExeStage().isSyncEnabled()) {
+ return;
+ }
+ try {
+ doPushClaimToHcs(flowId);
+ } catch (Exception exception) {
+ log.error("HCS收款认领链推送异常 flowId:{}", flowId, exception);
+ }
+ }
+
+ /**
+ * 推送主体:归集有效认领分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同)
+ */
+ private void doPushClaimToHcs(Long flowId) {
+ KingdeeReceiptFlow flow = receiptFlowMapper.selectById(flowId);
+ if (flow == null) {
+ return;
+ }
+ // 未作废认领主单(作废认领不同步合规)
+ List claims = claimMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ReceiptClaim::getReceiptFlowId, flowId)
+ .eq(ReceiptClaim::getClaimStatus, CLAIMED));
+ if (claims.isEmpty()) {
+ return;
+ }
+ // 有效分摊行(作废时已置 status=0)
+ List allocations = claimSettlementMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
+ .eq(ReceiptClaimSettlement::getStatus, 1)
+ .eq(ReceiptClaimSettlement::getIsDeleted, 0));
+ if (allocations.isEmpty()) {
+ return;
+ }
+ // 结算单批量定位合同(一次 IN 查询,避免逐行 N+1)
+ Map settlements = formalSettlementMapper.selectBatchIds(
+ allocations.stream().map(ReceiptClaimSettlement::getFormalSettlementId)
+ .filter(Objects::nonNull).distinct().toList()).stream()
+ .collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first));
+ // 合同id → 认领主单id → 分摊金额
+ Map> amountByContractByClaim = new LinkedHashMap<>();
+ for (ReceiptClaimSettlement allocation : allocations) {
+ FormalSettlement settlement = settlements.get(allocation.getFormalSettlementId());
+ if (settlement == null || settlement.getContractId() == null
+ || allocation.getReceiptClaimId() == null || allocation.getAllocatedReceiptAmount() == null) {
+ continue;
+ }
+ amountByContractByClaim
+ .computeIfAbsent(settlement.getContractId(), key -> new LinkedHashMap<>())
+ .merge(allocation.getReceiptClaimId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add);
+ }
+ if (amountByContractByClaim.isEmpty()) {
+ log.info("HCS收款认领链推送跳过:分摊行未关联合同 flowId={}", flowId);
+ return;
+ }
+ // 合规侧收款流水ID(C107-1 必填引用,一次定位全部合同共用;定位失败仅跳过 C107-1,不阻塞履约计划更新)
+ String hcsCollectionFlowId = null;
+ try {
+ hcsCollectionFlowId = locateHcsCollectionFlowId(flow);
+ } catch (Exception exception) {
+ log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flowId, exception);
+ }
+ for (Map.Entry> contractEntry : amountByContractByClaim.entrySet()) {
+ try {
+ pushOneContract(flow, claims, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId);
+ } catch (Exception exception) {
+ log.error("HCS收款认领链推送失败 flowId:{},contractId:{}", flowId, contractEntry.getKey(), exception);
+ }
+ }
+ }
+
+ /**
+ * 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(按认领主单分组)→ ③ C108-1 履约流水
+ */
+ private void pushOneContract(KingdeeReceiptFlow flow, List claims, Long contractId,
+ Map amountByClaim, String hcsCollectionFlowId) {
+ ContractManage contractManage = contractManageMapper.selectById(contractId);
+ if (contractManage == null) {
+ log.warn("HCS收款认领链推送跳过:合同不存在 contractId={}", contractId);
+ return;
+ }
+ BigDecimal claimedTotal = amountByClaim.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ // ① C103-9 更新合同履约计划:认领金额累加到收款计划行;已同步行置 update 凭阶段ID 重推
+ contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimedTotal);
+ HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow(contractId, HcsSyncConstant.FUND_NATURE_PROGRESS);
+ if (StringUtil.isNotBlank(contractManage.getHcsFdId())) {
+ // 行级重推(无待同步行返回提示,不视为失败);合同未推送合规时随合同链路首推
+ hcsExeStageSyncSupport.syncExeStages(contractManage);
+ }
+ // ② C107-1 认领记录:按「认领主单 × 合同」推送,金额为该认领主单分摊到本合同的金额
+ if (StringUtil.isBlank(hcsCollectionFlowId)) {
+ log.warn("HCS认领记录推送跳过:未定位到合规收款流水 flowId={},detailSerialNo={}", flow.getId(), flow.getDetailSerialNo());
+ } else {
+ Map claimsById = claims.stream()
+ .collect(Collectors.toMap(ReceiptClaim::getId, item -> item, (first, second) -> first));
+ for (Map.Entry claimEntry : amountByClaim.entrySet()) {
+ pushClaimRecord(flow, claimsById.get(claimEntry.getKey()), contractManage, stageRow, claimEntry.getValue(), hcsCollectionFlowId);
+ }
+ }
+ // ③ C108-1 履约流水:按合同一条(开关 perf-record.sync-enabled,与付款链履约记录同步同开关),
+ // 须已推送合规且存在已同步收款计划行(fd_plan_id 必填)
+ if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
+ log.info("HCS履约流水推送跳过:履约记录同步开关未开启 contractNo={}", contractManage.getContractNo());
+ return;
+ }
+ if (StringUtil.isBlank(contractManage.getHcsFdId())) {
+ log.info("HCS履约流水推送跳过:合同尚未推送合规 contractNo={}", contractManage.getContractNo());
+ return;
+ }
+ if (stageRow == null || StringUtil.isBlank(stageRow.getHcsStageFdId())) {
+ log.info("HCS履约流水推送跳过:无已同步收款履约计划行 contractNo={}", contractManage.getContractNo());
+ return;
+ }
+ pushPerfRecord(flow, contractManage, stageRow, claimedTotal, latestClaimDate(claims));
+ }
+
+ /**
+ * C107-1 认领记录推送(单认领主单 × 单合同)
+ */
+ private void pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage,
+ HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) {
+ if (claim == null || claimAmount == null || claimAmount.signum() <= 0) {
+ return;
+ }
+ HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO();
+ // 【必填】收款流水引用(合规侧流水ID,C104-2 按明细流水号定位)
+ HcsRefVO collectionFlow = new HcsRefVO();
+ collectionFlow.setFdId(hcsCollectionFlowId);
+ param.setFdCollectionFlow(collectionFlow);
+ // 认领合同引用(fdId 为合规合同ID)
+ HcsRefVO contractRef = new HcsRefVO();
+ contractRef.setFdId(contractManage.getHcsFdId());
+ contractRef.setFdName(contractManage.getContractName());
+ param.setFdContract(contractRef);
+ // 【必填】认领合同编号(须在合规系统本地存在)
+ param.setFdContractNumber(contractManage.getContractNo());
+ // 对应履约项ID 与 认领款项类型(取合同收款计划行,无已同步行不传)
+ if (stageRow != null) {
+ param.setFdExePlanStageId(stageRow.getHcsStageFdId());
+ param.setFdFundNature(stageRow.getFundNature());
+ }
+ // 认领人/部门:TMS 与合规用户不同源,仅传名称(§9-U2)
+ param.setFdClaimer(HcsRefVO.ofName(claim.getClaimerName()));
+ param.setFdClaimerName(claim.getClaimerName());
+ param.setFdClaimerDept(HcsRefVO.ofName(claim.getClaimerDeptName()));
+ param.setFdClaimerDeptName(claim.getClaimerDeptName());
+ // 【必填】认领金额
+ param.setFdClaimAmount(claimAmount);
+ param.setFdClaimDate(toMillis(claim.getClaimDate()));
+ param.setFdRemark(claim.getRemark());
+ // 状态 0=已认领(仅推送未作废认领)
+ param.setFdStatus(0);
+ log.info("HCS认领记录推送开始 flowId:{},claimId:{},contractNo:{},认领金额 {}",
+ flow.getId(), claim.getId(), contractManage.getContractNo(), claimAmount);
+ HcsResult result = hcsClient.claimRecordAdd(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS认领记录推送失败响应:{},claimId={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), claim.getId());
+ throw new HcsException("C107-1 认领记录推送失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ // shortcut: C107-1 返回 fdId 未持久化(认领作废同步 C107-3 未实现,重推会重复);需作废联动时先落库
+ log.info("HCS认领记录推送成功 flowId:{},claimId:{},contractNo:{},claimRecordFdId:{}",
+ flow.getId(), claim.getId(), contractManage.getContractNo(),
+ result.getData() == null ? "响应未返回" : result.getData().getFdId());
+ }
+
+ /**
+ * C108-1 履约流水推送(单合同一条:申请金额=实际金额=该合同累计认领金额)
+ */
+ private void pushPerfRecord(KingdeeReceiptFlow flow, ContractManage contractManage,
+ HcsContractExeStage stageRow, BigDecimal claimedTotal, LocalDate planDate) {
+ // 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传)
+ String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId();
+ HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
+ // 【必填】申请编号:收款通知单号,兜底明细流水号
+ props.setFdApplyNo(firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo()));
+ // 申请金额 / 实际金额:该合同累计认领金额
+ props.setFdApplyAmount(claimedTotal.toPlainString());
+ props.setFdFactAmount(claimedTotal.toPlainString());
+ // 【必填】履约日期:最近认领日期(毫秒时间戳)
+ props.setFdPlanDate(toMillis(planDate));
+ // 履约类型:收款
+ props.setFdPalnType(HcsSyncConstant.PERFORM_TYPE_COLLECTION);
+ // 履约性质:收款计划行款项性质
+ props.setFdProperty(stageRow.getFundNature());
+ // 履约计划id / 来源履约计划项ID:收款计划行合规阶段ID(HCS 只认自己的阶段ID)
+ props.setFdId(stageRow.getHcsStageFdId());
+ props.setFdPlanId(stageRow.getHcsStageFdId());
+ // 金蝶单据号:A301-3 推送返回的金蝶收款单号
+ props.setFdJdDocNo(flow.getKingdeeBillNo());
+ // 【必填】合同ID:HCS 合同ID
+ props.setFdContractId(contractManage.getHcsFdId());
+ // 业务ID:TMS 收款流水ID(溯源)
+ props.setFdBusinessId(String.valueOf(flow.getId()));
+ props.setFdCreateTime(System.currentTimeMillis());
+ props.setFdXformId(xformId);
+ HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO();
+ formData.setFdXformId(xformId);
+ formData.setDynamicProps(props);
+ // 机制数据(C108-1 必填):认领附件暂不转传,传空列表
+ HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
+ mechanisms.setAttachment(List.of());
+ HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO();
+ param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName()));
+ param.setFormData(formData);
+ param.setMechanisms(mechanisms);
+ log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimedTotal);
+ HcsResult result = hcsClient.perfRecordAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS履约流水新建失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
+ throw new HcsException("C108-1 履约流水新建失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ // shortcut: 履约流水 fdId 未持久化(认领链无 C108-2 更新场景);需推进实际金额更新时先落库
+ log.info("HCS履约流水新建成功 flowId:{},contractNo:{},perfFdId:{}",
+ flow.getId(), contractManage.getContractNo(), result.getData().getFdId());
+ }
+
+ /**
+ * C104-2 定位合规侧收款流水ID:按明细流水号精确查询(合规流水由合规自 ERP 同步,TMS 镜像同源)
+ */
+ private String locateHcsCollectionFlowId(KingdeeReceiptFlow flow) {
+ if (StringUtil.isBlank(flow.getDetailSerialNo())) {
+ log.warn("HCS收款流水定位跳过:流水缺少明细流水号 flowId={}", flow.getId());
+ return null;
+ }
+ HcsPageQueryDTO param = new HcsPageQueryDTO();
+ param.setColumns(List.of("fdId", "fdClaimNotice", "fdDetailSerialNumber", "fdCollectionAmount", "fdClaimStatus", "fdClaimedAmount"));
+ param.setConditions(Map.of("fdDetailSerialNumber", flow.getDetailSerialNo()));
+ param.setOffset(0);
+ param.setPageSize(10);
+ HcsResult> result = hcsClient.collectionFlowList(param);
+ if (result == null || !result.isSuccess() || result.getData() == null) {
+ throw new HcsException("C104-2 收款流水查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getContent().stream()
+ .filter(item -> flow.getDetailSerialNo().equals(item.getFdDetailSerialNumber()))
+ .map(HcsCollectionFlowVO::getFdId)
+ .filter(StringUtil::isNotBlank)
+ .findFirst().orElse(null);
+ }
+
+ /**
+ * 最近认领日期(C108-1 履约日期)
+ */
+ private LocalDate latestClaimDate(List claims) {
+ return claims.stream()
+ .map(ReceiptClaim::getClaimDate)
+ .filter(Objects::nonNull)
+ .max(LocalDate::compareTo)
+ .orElse(LocalDate.now());
+ }
+
+ private String firstNonBlank(String... values) {
+ for (String value : values) {
+ if (Func.isNotEmpty(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+
+ private Long toMillis(LocalDate localDate) {
+ return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSpecSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSpecSyncSupport.java
new file mode 100644
index 00000000..67d98ca2
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSpecSyncSupport.java
@@ -0,0 +1,284 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsContractDetailQueryDTO;
+import org.springblade.hcs.dto.HcsContractDetailVO;
+import org.springblade.hcs.dto.HcsLegalResultDTO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.HcsContractCheckLogMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.HcsContractCheckLog;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * HCS 合同特批同步支撑组件(合规特批结果接收:contractLegal/result 推送 + C103-4 定时查询兜底 + 人工确认)。
+ *
+ * 业务闭环(2026-10-13 起):高风险/黑名单客商合同随 C103-1/C103-11 报文 fdSepcailContract=true 在合规侧走特批流程
+ * (C103-8 独立发起接口合规未提供,已删除),TMS 挂起"特批审核"(processing)→
+ * 集团审批结果经合规 contractLegal/result 接口推送 TMS(替代未建设的 D103),或定时 C103-4 查询兜底联动 →
+ * approved 恢复流程,rejected 联动合同审批驳回。
+ *
+ * 结果推送报文 {sourceSystem, requestId=合规合同ID, id, status=APPROVED/REJECTED, reason},原文留痕 blade_hcs_check_log。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsContractSpecSyncSupport {
+
+ /**
+ * C103-4 合同审批状态:已审批(fdApprovalStatus,小写枚举)
+ */
+ private static final String LEGAL_STATUS_APPROVED = "approved";
+
+ /**
+ * C103-4 合同审批状态:审批作废(特批驳回映射待联调确认,暂按作废联动驳回)
+ */
+ private static final String LEGAL_STATUS_INVALID = "approvalinvalid";
+
+ /**
+ * 结果推送原文留痕最大长度(blade_hcs_check_log.msg 为 varchar(512))
+ */
+ private static final int CALLBACK_MSG_MAX_LENGTH = 500;
+
+ private final IHcsClient hcsClient;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
+ private final HcsProperties hcsProperties;
+
+ /**
+ * 特批结果定时查询兜底(合规推送 contractLegal/result 未达/未配置时的补偿,2026-10-13):
+ * 扫描特批中(processing)的合同,按 C103-4 合同详情 fdApprovalStatus 联动——
+ * approved 恢复"审批通过"、approvalinvalid 联动审批驳回(特批驳回的合规侧状态待联调确认,暂按作废映射)、其余等待中跳过;
+ * 与结果推送互不冲突(推送先到则状态已非 processing,查询自然跳过)
+ *
+ * @return 结果汇总(供任务日志)
+ */
+ public String syncProcessingSpecResults() {
+ List contracts = contractManageMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ContractManage::getHcsSpecStatus, HcsSyncConstant.SPEC_STATUS_PROCESSING)
+ .isNotNull(ContractManage::getHcsFdId)
+ .last("limit 50"));
+ if (contracts.isEmpty()) {
+ return "特批结果查询:无特批中合同";
+ }
+ int approvedCount = 0;
+ int rejectedCount = 0;
+ for (ContractManage contractManage : contracts) {
+ try {
+ HcsContractDetailQueryDTO query = new HcsContractDetailQueryDTO();
+ // 【定位】合规合同ID:C103-4 fdId 与 fdNumber 二选一
+ query.setFdId(contractManage.getHcsFdId());
+ HcsResult result = hcsClient.contractDetail(query);
+ if (result == null || !result.isSuccess() || result.getData() == null) {
+ log.warn("HCS特批结果查询失败响应:contractNo={},{}", contractManage.getContractNo(),
+ result == null ? "无响应" : result.getMsg());
+ continue;
+ }
+ String approvalStatus = result.getData().getFdApprovalStatus();
+ if (LEGAL_STATUS_APPROVED.equalsIgnoreCase(approvalStatus)) {
+ syncContractOnSpecResult(contractManage, true);
+ markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_APPROVED, "特批结果查询联动:fdApprovalStatus=approved");
+ writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_PASS, "特批结果查询联动:fdApprovalStatus=approved", null);
+ approvedCount++;
+ } else if (LEGAL_STATUS_INVALID.equalsIgnoreCase(approvalStatus)) {
+ syncContractOnSpecResult(contractManage, false);
+ markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_REJECTED, "特批结果查询联动:fdApprovalStatus=approvalinvalid");
+ writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_FAIL, "特批结果查询联动:fdApprovalStatus=approvalinvalid", null);
+ rejectedCount++;
+ }
+ } catch (Exception exception) {
+ // 单条失败不影响其余,下轮扫描自动重试
+ log.warn("HCS特批结果查询单条失败(下轮重试):contractNo={},{}", contractManage.getContractNo(), exception.getMessage());
+ }
+ }
+ return "特批结果查询:扫描 " + contracts.size() + " 单,联动通过 " + approvedCount + ",联动驳回 " + rejectedCount;
+ }
+
+ /**
+ * 特批结果人工确认(§6-U1 过渡方案:页面确认与合规推送等效)
+ *
+ * @param id 合同ID
+ * @param result approved/rejected
+ * @return 结果描述
+ */
+ public String confirmSpecResult(Long id, String result) {
+ ContractManage contractManage = contractManageMapper.selectById(id);
+ if (contractManage == null) {
+ throw new ServiceException("合同单不存在:" + id);
+ }
+ if (!Arrays.asList(HcsSyncConstant.SPEC_STATUS_APPROVED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(result)) {
+ throw new ServiceException("特批结果仅支持 approved/rejected");
+ }
+ if (!HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())) {
+ throw new ServiceException("仅特批中的合同允许确认结果(当前状态:" + contractManage.getHcsSpecStatus() + ")");
+ }
+ // 特批结果联动合同审批状态(通过恢复展示,驳回联动审批驳回),随 markSpec 一并落库
+ syncContractOnSpecResult(contractManage, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result));
+ markSpec(contractManage, result, "特批结果人工确认:" + result);
+ writeCheckLog(contractManage, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)
+ ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL, "特批结果人工确认:" + result, null);
+ log.info("HCS合同特批结果人工确认:contractNo={},specFdId={},结果={}", contractManage.getContractNo(), contractManage.getHcsSpecFdId(), result);
+ return "特批结果已确认:" + result;
+ }
+
+ /**
+ * 合同特批结果接收处理(合规 contractLegal/result 推送,2026-10-23 按联调实际报文修正):
+ * 报文 {sourceSystem, requestId=合规合同ID, id, status=APPROVED/REJECTED, reason};
+ * sourceSystem 须与 third-party.hcs.src-system 一致(不符抛"来源系统标识错误"异常);
+ * requestId 即合规合同ID(与 id 同值),按 blade_contract_manage.hcs_fd_id 定位合同;
+ * approved 联动恢复"审批通过",rejected 联动合同审批驳回走重新提交闭环(reason 随原文留痕)
+ *
+ * @param param 合规推送报文
+ * @return 处理结果描述
+ */
+ public String handleLegalResult(HcsLegalResultDTO param) {
+ // 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
+ if (!StrUtil.equalsIgnoreCase(param == null ? null : param.getSourceSystem(), hcsProperties.getSrcSystem())) {
+ log.warn("合同特批结果推送来源系统标识错误:sourceSystem={}", param == null ? null : param.getSourceSystem());
+ throw new ServiceException("来源系统标识错误");
+ }
+ String raw = JSONUtil.toJsonStr(param);
+ String requestId = param.getRequestId();
+ ContractManage contractManage = locateContractByFdId(requestId);
+ if (contractManage == null) {
+ log.warn("合同特批结果推送未定位到合同:requestId={},报文已留痕", requestId);
+ writeCallbackLog(null, HcsSyncConstant.CHECK_RESULT_SKIP, "合同特批结果推送未定位到合同:" + raw);
+ return "已留痕:未定位到合同(requestId=" + requestId + ")";
+ }
+ String normalizedStatus = param.getStatus() == null ? "" : param.getStatus().trim();
+ if (!HcsSyncConstant.LEGAL_RESULT_APPROVED.equalsIgnoreCase(normalizedStatus)
+ && !HcsSyncConstant.LEGAL_RESULT_REJECTED.equalsIgnoreCase(normalizedStatus)) {
+ log.warn("合同特批结果推送状态未识别:contractNo={},status={},报文已留痕", contractManage.getContractNo(), param.getStatus());
+ writeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, "合同特批结果推送状态未识别:" + raw);
+ return "已留痕:状态字段未识别(contractNo=" + contractManage.getContractNo() + ")";
+ }
+ boolean approved = HcsSyncConstant.LEGAL_RESULT_APPROVED.equalsIgnoreCase(normalizedStatus);
+ // 特批结果联动合同审批状态(通过恢复展示,驳回联动审批驳回),随 markSpec 一并落库
+ syncContractOnSpecResult(contractManage, approved);
+ markSpec(contractManage, approved ? HcsSyncConstant.SPEC_STATUS_APPROVED : HcsSyncConstant.SPEC_STATUS_REJECTED,
+ "合同特批结果推送:" + normalizedStatus + (StringUtil.isBlank(param.getReason()) ? "" : ",reason=" + param.getReason()));
+ writeCallbackLog(contractManage, approved ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL,
+ "合同特批结果推送:" + raw);
+ log.info("合同特批结果推送处理完成:contractNo={},结果={},reason={}", contractManage.getContractNo(), normalizedStatus, param.getReason());
+ return "特批结果已处理:" + (approved ? "approved" : "rejected");
+ }
+
+ /**
+ * 按合规合同ID定位合同(合规推送 requestId/id,即 blade_contract_manage.hcs_fd_id)
+ */
+ public ContractManage locateContractByFdId(String requestId) {
+ if (StringUtil.isBlank(requestId)) {
+ return null;
+ }
+ return contractManageMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ContractManage::getHcsFdId, requestId.trim())
+ .last("limit 1"));
+ }
+
+ /**
+ * 特批结果联动合同审批状态(handleLegalResult 与 confirmSpecResult 共用,仅改字段,随 markSpec 落库):
+ * approved 恢复"审批通过"展示(C103-1 已起草完成,hcs_sync_status 保持 synced,无需再推送);
+ * rejected 联动合同审批驳回(approvalStatus=rejected),走重新编辑→提交→审批→特批闭环
+ */
+ private void syncContractOnSpecResult(ContractManage contractManage, boolean approved) {
+ if (approved) {
+ contractManage.setCurrentNode("审批通过");
+ } else {
+ contractManage.setApprovalStatus("rejected");
+ contractManage.setCurrentNode("已驳回");
+ contractManage.setCurrentProcessor("待处理");
+ }
+ }
+
+ /**
+ * 回写特批状态与动作时间
+ */
+ private void markSpec(ContractManage contractManage, String status, String message) {
+ contractManage.setHcsSpecStatus(status);
+ contractManage.setHcsSpecTime(LocalDateTime.now());
+ contractManage.setHcsSpecMsg(StringUtil.left(message, 500));
+ contractManageMapper.updateById(contractManage);
+ }
+
+ /**
+ * 特批留痕(blade_hcs_check_log,biz_type=contract,check_type=spec,异常不影响主流程)
+ */
+ private void writeCheckLog(ContractManage contractManage, String result, String msg, String traceId) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ checkLog.setTenantId(contractManage.getTenantId());
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
+ checkLog.setBizId(contractManage.getId());
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC);
+ checkLog.setResult(result);
+ checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH));
+ checkLog.setTraceId(traceId);
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("HCS合同特批留痕写入失败:contractNo={}", contractManage.getContractNo(), exception);
+ }
+ }
+
+ /**
+ * 结果推送留痕(合同未定位到时 bizId 为空,原文留痕便于联调解析)
+ */
+ private void writeCallbackLog(ContractManage contractManage, String result, String msg) {
+ if (contractManage == null) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC);
+ checkLog.setResult(result);
+ checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH));
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("合同特批结果推送留痕写入失败", exception);
+ }
+ return;
+ }
+ writeCheckLog(contractManage, result, msg, null);
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java
new file mode 100644
index 00000000..30fe801d
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java
@@ -0,0 +1,787 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.jackson.JsonUtil;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.constant.HcsConstant;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsContractDraftDTO;
+import org.springblade.hcs.dto.HcsContractDraftVO;
+import org.springblade.hcs.dto.HcsContractFieldJsonDTO;
+import org.springblade.hcs.dto.HcsContractLegalResultDTO;
+import org.springblade.hcs.dto.HcsContractVO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsRiskQueryDTO;
+import org.springblade.hcs.dto.HcsRiskVO;
+import org.springblade.hcs.dto.HcsSignerDTO;
+import org.springblade.hcs.dto.HcsXformFieldDTO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsContractFieldConstant;
+import org.springblade.transport.constant.HcsFieldConstant;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.CustomerArchiveMapper;
+import org.springblade.transport.mapper.HcsContractCheckLogMapper;
+import org.springblade.transport.mapper.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.CustomerArchive;
+import org.springblade.transport.pojo.entity.HcsContractCheckLog;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 合同同步支撑组件(C103-1 发起 / C103-11 更新,设计文档 §5.2)
+ *
+ * 扫描任务按序执行:⓪ 特批挂起门禁 → ① 客商风险校验(可配开关,仅留痕,高风险不再阻断)→
+ * ② 附件转传(挂载点为与 fdFieldJson 同级的顶层 attachment,随 C103-1 挂载)→ ③ C103-3 判重(§9-U4 查询能力联调确认)→
+ * ④ C103-1 起草 / 变更单 C103-11 更新(§9-U5 定位方式待联调)→ ⑤ 履约计划 C103-9 行级同步({@link HcsExeStageSyncSupport},
+ * 仅依赖 hcs_fd_id,先于特批挂起执行,失败补偿不被特批门禁阻塞)→ ⑥ 高风险/黑名单挂起"特批审核"
+ * (外部客商管控:报文随 fdSepcailContract=true 在合规侧走特批,{@link HcsContractSpecSyncSupport} 接收结果)。
+ * 结果留痕:风险校验写 blade_hcs_check_log 与合同 hcs_risk_* 字段;推送结果回填 hcs_fd_id/hcs_sync_status。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsContractSyncSupport {
+
+ /**
+ * 合同金额等 xformFields 值一律字符串传值;时间字段 v4 要求 yyyy-MM-dd(fdType=timestamp)
+ */
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ /**
+ * C101-1 黑名单状态取值(示例 no 表示非黑名单,§9-U3 字典值联调确认)
+ */
+ private static final String BLACK_STATUS_YES = "yes";
+
+
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ContractManageMapper contractManageMapper;
+ private final CustomerArchiveMapper customerArchiveMapper;
+ private final ProjectApplyMapper projectApplyMapper;
+ private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+ private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
+ private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport;
+
+ /**
+ * 合同金蝶同步结果接收处理(合规 contractKingdee/result 推送,D903,投送人/钢厂/结算中心合同共用):
+ * 合规侧收到金蝶最终同步结果后通知 TMS 更新同步状态,本接口不处理法务审批结论(特批联动走 contractLegal/result + C103-4 兜底);
+ * contractType(deliverer/gc/sett_center)非必填仅随原文留痕;sourceSystem 非必填,传值时须为 ;
+ * contractCode=TMS 合同编号定位(requestId 仅 X-ERP-Request-Id 回显不可定位);
+ * success=true 回填金蝶合同ID(kingdeeId),success=false 记录失败原因;均追加变更记录并原文留痕 blade_hcs_check_log
+ *
+ * @param param 合规推送报文
+ * @return 处理结果描述
+ */
+ public String handleKingdeeResult(HcsContractLegalResultDTO param) {
+ // sourceSystem 非必填(D903),传值时仅接受合规方约定的 ,防伪造回传
+ String sourceSystem = param == null ? null : param.getSourceSystem();
+ if (!StrUtil.equalsIgnoreCase(param == null ? null : param.getSourceSystem(), hcsProperties.getSrcSystem())) {
+ log.warn("合同金蝶同步结果推送来源系统标识错误:sourceSystem={}", sourceSystem);
+ throw new ServiceException("来源系统标识错误");
+ }
+ String raw = JSONUtil.toJsonStr(param);
+ String requestId = param.getRequestId();
+ ContractManage contractManage = hcsContractSpecSyncSupport.locateContractByFdId(requestId);
+ if (contractManage == null) {
+ log.warn("合同金蝶同步结果推送未定位到合同:contractCode={},报文已留痕", param.getContractCode());
+ writeKingdeeCallbackLog(null, HcsSyncConstant.CHECK_RESULT_SKIP, "合同金蝶同步结果推送未定位到合同:" + raw);
+ return "已留痕:未定位到合同(contractCode=" + param.getContractCode() + ")";
+ }
+ if (param.getSuccess() == null) {
+ log.warn("合同金蝶同步结果推送 success 字段缺失:contractNo={},报文已留痕", contractManage.getContractNo());
+ writeKingdeeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, "合同金蝶同步结果推送 success 字段缺失:" + raw);
+ return "已留痕:success 字段缺失(contractNo=" + contractManage.getContractNo() + ")";
+ }
+ if (param.getSuccess()) {
+ // 成功:回填金蝶合同ID,同步结果信息随 hcs_sync_msg 留痕,不联动审批/特批状态
+ contractManage.setKingdeeId(param.getKingdeeId());
+ contractManage.setHcsSyncMsg(HcsSyncConstant.truncateMsg("合同金蝶同步结果回调:金蝶同步成功,kingdeeId=" + param.getKingdeeId()));
+ appendKingdeeResultRecord(contractManage, "金蝶同步成功:金蝶合同ID=" + param.getKingdeeId());
+ contractManageMapper.updateById(contractManage);
+ writeKingdeeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_PASS, "合同金蝶同步结果推送:" + raw);
+ log.info("合同金蝶同步结果推送处理完成:contractNo={},结果=success,kingdeeId={}", contractManage.getContractNo(), param.getKingdeeId());
+ return "金蝶同步结果已处理:success";
+ }
+ // 失败:记录失败原因(金蝶同步在合规侧完成,TMS 不重推,失败重试由合规侧负责)
+ contractManage.setHcsSyncMsg(HcsSyncConstant.truncateMsg("合同金蝶同步结果回调:金蝶同步失败:" + param.getReason()));
+ appendKingdeeResultRecord(contractManage, "金蝶同步失败:" + param.getReason());
+ contractManageMapper.updateById(contractManage);
+ writeKingdeeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_FAIL, "合同金蝶同步结果推送:" + raw);
+ log.info("合同金蝶同步结果推送处理完成:contractNo={},结果=fail,reason={}", contractManage.getContractNo(), param.getReason());
+ return "金蝶同步结果已处理:fail";
+ }
+
+ /**
+ * 按 ERP 合同编号定位合同(合规推送 contractCode,合同编号唯一)
+ */
+ private ContractManage locateContractByNo(String contractCode) {
+ if (StringUtil.isBlank(contractCode)) {
+ return null;
+ }
+ return contractManageMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ContractManage::getContractNo, contractCode.trim())
+ .last("limit 1"));
+ }
+
+ /**
+ * 合同金蝶同步结果推送留痕(blade_hcs_check_log,biz_type=contract,合同未定位到时 bizId 为空,异常不影响主流程)
+ */
+ private void writeKingdeeCallbackLog(ContractManage contractManage, String result, String msg) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ if (contractManage != null) {
+ checkLog.setTenantId(contractManage.getTenantId());
+ checkLog.setBizId(contractManage.getId());
+ }
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_KINGDEE);
+ checkLog.setResult(result);
+ checkLog.setMsg(StringUtil.left(msg, HcsSyncConstant.SYNC_MSG_MAX_LENGTH));
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("合同金蝶同步结果推送留痕写入失败:contractNo={}", contractManage == null ? null : contractManage.getContractNo(), exception);
+ }
+ }
+
+ /**
+ * 金蝶同步结果回填变更记录(changeRecordJson 追加一条;系统回调无登录用户,操作人记来源标识)
+ */
+ private void appendKingdeeResultRecord(ContractManage contractManage, String changeContent) {
+ List records = StringUtil.isBlank(contractManage.getChangeRecordJson())
+ ? new ArrayList<>() : new ArrayList<>(JSONUtil.toList(contractManage.getChangeRecordJson(), Object.class));
+ Map record = new LinkedHashMap<>();
+ record.put("changeDate", LocalDate.now().toString());
+ record.put("handlerUserName", "合规系统回调");
+ record.put("changeType", "金蝶同步结果");
+ record.put("changeContent", changeContent);
+ record.put("status", contractManage.getApprovalStatus());
+ record.put("statusName", HcsSyncConstant.approvalStatusName(contractManage.getApprovalStatus()));
+ records.add(record);
+ contractManage.setChangeRecordJson(JSONUtil.toJsonStr(records));
+ }
+
+ /**
+ * 推送合同到 HCS:特批挂起门禁 → 风险校验(仅留痕,高风险不再阻断)→ 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced
+ * → 履约计划行级同步(C103-9,先于特批挂起)→ 高风险/黑名单客商合同挂起"特批审核"等待合规特批结果(报文随 fdSepcailContract=true,外部客商管控)
+ *
+ * @param contractManage 合同单(approvalStatus 须为 approved/change_approved)
+ * @return 推送结果描述(供任务汇总与 hcs_sync_msg 落库)
+ */
+ public String pushContract(ContractManage contractManage) {
+ log.info("HCS合同推送开始:contractNo={},approvalStatus={},首推={},hcsSpecStatus={}",
+ contractManage.getContractNo(), contractManage.getApprovalStatus(),
+ StringUtil.isBlank(contractManage.getHcsFdId()), contractManage.getHcsSpecStatus());
+ // ⓪ 特批挂起门禁:特批中(processing)的合同跳过推送,等待合规推送/定时查询特批结果后恢复;
+ // need(存量待特批/历史卡单)不拦截——重推时随起草报文带 fdSepcailContract=true 自愈转特批
+ if (HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())) {
+ log.info("HCS合同推送跳过(特批挂起中):contractNo={},hcsSpecStatus={}", contractManage.getContractNo(), contractManage.getHcsSpecStatus());
+ return "合同特批挂起中(processing),跳过推送";
+ }
+ // ① 客商风险校验(可配开关):留痕 hcs_risk_* 字段并返回高风险/黑名单标志,不再阻断推送(高风险合同起草后自动转特批)
+ boolean risky = false;
+ if (hcsProperties.getContract().isRequireRiskCheck()) {
+ risky = checkRelativeRisk(contractManage);
+ }
+ // ② 附件转传(v4 已明确挂载点:C103-1 顶层 attachment,与 fdFieldJson 同级,转传取得合规附件引用后随发起报文挂载)
+ List attachmentRefs = transferAttachmentsIfEnabled(contractManage);
+ // ③④ 首推判重后起草(C103-1);已有合规ID(变更单)走更新(C103-11)
+ String resultMessage;
+ if (StringUtil.isBlank(contractManage.getHcsFdId())) {
+ String existFdId = findExistFdId(contractManage);
+ if (StringUtil.isNotBlank(existFdId)) {
+ markSynced(contractManage, existFdId, "判重命中,回填已有合规合同ID");
+ resultMessage = "判重命中,回填已有合规合同ID";
+ } else {
+ String fdContractId = draftContract(contractManage, attachmentRefs, risky);
+ // 先持久化合规合同ID(调用方必须保存),履约规则同步失败时下轮凭其走更新补偿
+ resultMessage = "合同发起成功:" + contractManage.getContractNo();
+ markSynced(contractManage, fdContractId, resultMessage);
+ }
+ } else {
+ updateContract(contractManage, risky);
+ resultMessage = "合同更新成功:" + contractManage.getContractNo();
+ markSynced(contractManage, contractManage.getHcsFdId(), resultMessage);
+ }
+ // ⑤ 履约规则 C103-9 行级同步(随合同链路,§5.3;仅依赖 hcs_fd_id 与特批无关,先于特批挂起执行——
+ // 失败抛出落 failed 时特批未挂起,修复后可直接重推补偿,不被特批门禁阻塞)
+ if (hcsProperties.getExeStage().isSyncEnabled()) {
+ resultMessage = resultMessage + ";" + hcsExeStageSyncSupport.syncExeStages(contractManage);
+ }
+ // ⑥ 高风险/黑名单客商(外部客商管控):起草/更新报文已随 fdSepcailContract=true 在合规侧发起特批流程,
+ // 此处挂起"特批审核"等待结果(合规推送 contractLegal/result 或定时 C103-4 查询联动恢复/驳回)
+ if (risky) {
+ hangSpecReview(contractManage);
+ }
+ return resultMessage;
+ }
+
+ /**
+ * C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段;
+ * 返回是否高风险/黑名单(风险等级=2 或任一方黑名单),不再阻断推送——
+ * 推送成功后由自动特批链路挂起处理(外部客商管控,见 {@link #})
+ *
+ * @return true=高风险或黑名单客商(触发自动特批)
+ */
+ private boolean checkRelativeRisk(ContractManage contractManage) {
+ RiskResult riskPartyA = queryRisk(contractManage, "甲方", contractManage.getPartyA());
+ RiskResult riskPartyB = queryRisk(contractManage, "乙方", contractManage.getPartyB());
+ // 风险等级:甲乙双方取先查到的非空值(无法量化比较,留痕双方原始值,联调后细化)
+ String riskLevel = StringUtil.isNotBlank(riskPartyA.riskLevel()) ? riskPartyA.riskLevel() : riskPartyB.riskLevel();
+ // 黑名单状态:任一方黑名单即为是
+ boolean blacklist = riskPartyA.blacklist() || riskPartyB.blacklist();
+ contractManage.setHcsRiskLevel(riskLevel);
+ contractManage.setHcsBlackStatus(blacklist ? BLACK_STATUS_YES : "no");
+ contractManage.setHcsRiskTime(LocalDateTime.now());
+ contractManageMapper.updateById(contractManage);
+ boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || blacklist;
+ log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}],高风险/黑名单={}",
+ contractManage.getContractNo(), contractManage.getPartyA(), riskPartyA.blackStatus(), riskPartyA.riskLevel(),
+ contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel(), risky);
+ return risky;
+ }
+
+ /**
+ * 高风险/黑名单客商合同挂起"特批审核":合规侧特批流程已随 C103-1/C103-11 报文 fdSepcailContract=true 发起
+ * (C103-8 独立发起接口合规未提供,2026-10-13 联调确认),TMS 挂起展示"特批审核"(currentNode),
+ * 结果经合规推送(contractLegal/result)或定时 C103-4 查询联动后恢复或驳回;特批已通过(单次有效)不再重复挂起
+ */
+ private void hangSpecReview(ContractManage contractManage) {
+ // 终态守卫:合规回调(秒批/秒驳)先于挂起执行时,不得覆盖已落地的结果
+ // Arrays.asList 允许 null 元素:List.of 的不可变列表 contains(null) 会抛 NPE,而首次推送时 hcsSpecStatus 为 null
+ if (Arrays.asList(HcsSyncConstant.SPEC_STATUS_APPROVED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(contractManage.getHcsSpecStatus())) {
+ log.info("HCS合同高风险/黑名单但特批已有终态(单次有效),跳过挂起:contractNo={},hcsSpecStatus={}",
+ contractManage.getContractNo(), contractManage.getHcsSpecStatus());
+ return;
+ }
+ contractManage.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_PROCESSING);
+ contractManage.setHcsSpecTime(LocalDateTime.now());
+ contractManage.setHcsSpecMsg("高风险/黑名单客商,合同报文随 fdSepcailContract=true 走合规特批流程");
+ // TMS 流程挂起为"特批审核",特批通过恢复展示、驳回联动合同审批驳回
+ contractManage.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE);
+ contractManageMapper.updateById(contractManage);
+ log.info("HCS合同高风险/黑名单挂起特批审核:contractNo={},riskLevel={},blackStatus={},挂起节点={}",
+ contractManage.getContractNo(), contractManage.getHcsRiskLevel(), contractManage.getHcsBlackStatus(),
+ contractManage.getCurrentNode());
+ }
+
+ /**
+ * 解析 JSON 数组(照 ContractManage#parseJsonArray 惯例,异常返回空列表)
+ */
+ private List> parseJsonArray(String json) {
+ if (StringUtil.isBlank(json)) {
+ return List.of();
+ }
+ try {
+ List> result = new ArrayList<>();
+ for (Object item : JsonUtil.parse(json, List.class)) {
+ if (item instanceof Map, ?> map) {
+ result.add(map);
+ }
+ }
+ return result;
+ } catch (Exception exception) {
+ log.warn("合同附件元数据解析失败,按无附件处理:{}", json);
+ return List.of();
+ }
+ }
+
+ /**
+ * 查询单方客商风险:按名称反查客商档案(§9-U6 编码体系联调确认),反查不到记 skip 留痕不阻断;
+ * 内部组织客商默认无风险跳过(与展示/提交/付款链路口径一致,不调 C101-1)
+ */
+ private RiskResult queryRisk(ContractManage contractManage, String standing, String partyName) {
+ CustomerArchive customerArchive = queryCustomerArchive(partyName);
+ if (customerArchive == null) {
+ writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP,
+ standing + "方[" + partyName + "]反查不到客商编号,跳过风险校验(§9-U6)", null);
+ return new RiskResult(null, null, false);
+ }
+ // 内部组织类型客商默认无风险,不调 C101-1(与展示/提交/付款链路口径一致)
+ if (HcsCreditCheckSupport.isInternalCustomer(customerArchive)) {
+ writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP,
+ standing + "方[" + partyName + "]为内部组织客商,默认无风险跳过校验", null);
+ return new RiskResult(null, null, false);
+ }
+ HcsRiskQueryDTO param = new HcsRiskQueryDTO();
+ // 【必填】客商编号:按甲乙方名称反查 CustomerArchive 客商编码(§9-U6)
+ param.setFdNumber(customerArchive.getCustomerCode());
+ HcsResult result = hcsClient.getRelativeRisk(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS客商风险查询失败响应:{},contractNo={},standing={},fdNumber={}",
+ result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo(), standing, customerArchive.getCustomerCode());
+ throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsRiskVO risk = result.getData();
+ String blackStatus = risk == null ? null : risk.getFdBlackStatus();
+ String riskLevel = risk == null ? null : risk.getFdRiskLevel();
+ boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
+ writeCheckLog(contractManage, blacklist ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS,
+ standing + "方[" + partyName + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel, result.getTraceId());
+ return new RiskResult(blackStatus, riskLevel, blacklist);
+ }
+
+ /**
+ * C103-3 合同列表判重(conditions={"fdNumber": contractNo},§9-U4 查询能力联调确认)
+ */
+ private String findExistFdId(ContractManage contractManage) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ query.setColumns(List.of(HcsFieldConstant.CONTRACT_FD_ID, HcsFieldConstant.CONTRACT_FD_NUMBER));
+ // 判重条件:合同编号精确匹配
+ query.setConditions(Map.of(HcsFieldConstant.CONTRACT_FD_NUMBER, Objects.toString(contractManage.getContractNo(), "")));
+ query.setOffset(0);
+ query.setPageSize(10);
+ HcsResult> result = hcsClient.contractList(query);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS合同判重查询失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
+ throw new HcsException("C103-3 合同判重查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsPageVO page = result.getData();
+ if (page == null || page.getContent() == null || page.getContent().isEmpty()) {
+ return null;
+ }
+ return page.getContent().get(0).getFdId();
+ }
+
+ /**
+ * C103-1 合同发起,返回合规合同 fdContractId;risky=true 时报文带 fdSepcailContract=true 走合规特批流程
+ */
+ private String draftContract(ContractManage contractManage, List attachmentRefs, boolean risky) {
+ HcsContractDraftDTO param = buildDraftParam(contractManage, attachmentRefs, risky);
+ log.info("HCS合同推送开始:{}(C103-1 发起,挂载附件 {} 个,特批流程={})", contractManage.getContractNo(), attachmentRefs.size(), risky);
+ HcsResult result = hcsClient.contractDraft(param);
+ return validateDraftResult(contractManage, result);
+ }
+
+ /**
+ * C103-11 合同更新(入参同发起,v4 新增顶层 fdId 定位已有合同);risky=true 时报文带 fdSepcailContract=true
+ */
+ private void updateContract(ContractManage contractManage, boolean risky) {
+ HcsContractDraftDTO param = buildDraftParam(contractManage, null, risky);
+ log.info("HCS合同推送开始:{}(C103-11 更新,附件不重复挂载待联调确认,特批流程={})", contractManage.getContractNo(), risky);
+ HcsResult result = hcsClient.contractUpdate(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS合同更新失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
+ throw new HcsException("C103-11 合同更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ }
+
+ /**
+ * 组装 C103-1/C103-11 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.2);
+ * attachmentRefs 新建传合规附件引用列表(顶层 attachment,fdId+fdEntityKey,必填,无附件传空列表),更新传 null 不挂载
+ */
+ private HcsContractDraftDTO buildDraftParam(ContractManage contractManage, List attachmentRefs, boolean risky) {
+ HcsContractDraftDTO param = new HcsContractDraftDTO();
+ // 【必填】合同ID:C103-11 更新顶层必填(传 C103-1 返回的 fdContractId),C103-1 新建不传(v4 新增)
+ param.setFdId(contractManage.getHcsFdId());
+ // 【必填】合同模板ID:template-mapping[contractCategory](§9-U7 待合规方提供模板ID清单)
+ param.setFdTemplateId(requireTemplateId(contractManage));
+ // 【必填】外部系统标识:fd_sources,配置 thirdParty.hcs.src-system(D14)
+ param.setFdOuterSystem(hcsProperties.getSrcSystem());
+ // 【必填】操作人登录名:配置 thirdParty.hcs.operator-login-name(默认 srvadmin,§9-U2)
+ param.setFdLoginName(hcsProperties.getOperatorLoginName());
+ // 【必填】保存类型:submit(提交)
+ param.setSaveType("submit");
+ HcsContractFieldJsonDTO fieldJson = new HcsContractFieldJsonDTO();
+ fieldJson.setXformFields(buildXformFields(contractManage, risky));
+ // 相对方(签署方):甲方/乙方,编号按名称反查 CustomerArchive(§9-U6)
+ fieldJson.setSigner(buildSigners(contractManage));
+ // 【必填】附件数据:与 fdFieldJson 同级的顶层 attachment,C103-1 必填(无附件传空列表),fdEntityKey=特批 eopCdraftSpecial/用印 eopCdraftSign/其他 eopCdraftOther,
+ // fdBindType=ADD;更新不挂载(传 null)
+ if (attachmentRefs != null) {
+ param.setAttachment(new ArrayList<>(attachmentRefs));
+ for (HcsAttachmentRefDTO attachment : param.getAttachment()) {
+ // 【必填】操作类型:ADD 新增
+ attachment.setFdBindType("ADD");
+ }
+ }
+ param.setFdFieldJson(fieldJson);
+ return param;
+ }
+
+ /**
+ * xformFields 模板字段(字段名取 {@link HcsContractFieldConstant},中文名取自《桂物合同集成接口逻辑梳理》§5.1 合同字段说明)
+ */
+ private List buildXformFields(ContractManage contractManage, boolean risky) {
+ List fields = new ArrayList<>();
+ // 【必填】合同ID:更新时传合同发起返回的 fdContractId 的值(v4 合同字段说明)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_ID, "合同ID", contractManage.getHcsFdId()));
+ // 【必填】合同编号:ContractManage.contractNo
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_NUMBER, "合同编号", contractManage.getContractNo()));
+ fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NO, "合同编号", contractManage.getContractNo()));
+ // 【必填】合同标题:ContractManage.contractName
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_SUBJECT, "合同标题", contractManage.getContractName()));
+ fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NAME, "合同名称", contractManage.getContractName()));
+ // 【必填】合同金额:contractAmount(v4 字段表 fdAmount=Double,数值传值;AMOUNT 模板字段无类型说明照示例字符串传值)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_AMOUNT, "合同金额", contractManage.getContractAmount()));
+ fields.add(field(HcsContractFieldConstant.FIELD_AMOUNT, "合同金额", toPlainString(contractManage.getContractAmount())));
+ // 币种:TMS 结算币种经 currency-name-mapping 映射(默认 RMB→人民币,照金蝶惯例)
+ // fields.add(field(HcsContractFieldConstant.FIELD_FD_CURRENCY_ID, "币种", mapCurrencyName(contractManage.getSettlementCurrency())));
+ fields.add(field(HcsContractFieldConstant.FIELD_CURRENCY, "币种", mapCurrencyName(contractManage.getSettlementCurrency())));
+ // 合同结算类型:按合同类别固定规则——承运商合同=付款,客户合同=收款
+ // 传中文字典名(2026-10-17 确认;此前传枚举键 collection+fdIsSystem 合规侧未命中字典落为"不结算")
+ String settlementType = null;
+ if ("承运商合同".equals(contractManage.getContractCategory())) {
+ settlementType = HcsContractFieldConstant.SETTLEMENT_TYPE_PAYMENT;
+ } else if ("客户合同".equals(contractManage.getContractCategory())) {
+ settlementType = HcsContractFieldConstant.SETTLEMENT_TYPE_COLLECTION;
+ }
+ if (StringUtil.isNotBlank(settlementType)) {
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_SETTLEMENT_TYPE, "合同结算类型", settlementType));
+ } else {
+ log.warn("HCS合同结算类型无法确定:contractCategory=[{}] 非承运商合同/客户合同,fdSettlementType 不传", contractManage.getContractCategory());
+ }
+ // 结算模式:TMS 合同固定开口合同(open,v4 字段说明)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_CONTRACT_GW_NATURE, "结算模式", HcsContractFieldConstant.CONTRACT_GW_NATURE_OPEN));
+ // 特批流程:高风险/黑名单客商报文带 true(合规侧随起草自动走特批流程,C103-8 独立接口未提供);
+ // 配置项 contract.special-contract-enabled 可强制全量走特批(原文拼写笔误必须沿用,D15)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_SEPCAIL_CONTRACT, "特批流程", risky || hcsProperties.getContract().isSpecialContractEnabled() ? "true" : "false"));
+ // 合同类型:ContractManage.contractCategory
+ fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_TYPE, "合同类型", contractManage.getContractCategory()));
+ // 甲方名称/乙方名称:TMS 存名称字符串
+ fields.add(field(HcsContractFieldConstant.FIELD_PARTY_A, "甲方名称", contractManage.getPartyA()));
+ fields.add(field(HcsContractFieldConstant.FIELD_PARTY_B, "乙方名称", contractManage.getPartyB()));
+ // 【必填】签订日期:signDate(yyyy-MM-dd,fdType=timestamp)
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_SIGN_DATE, "签订日期", contractManage.getSignDate()));
+ // 预计开始/结束时间:startDate/endDate(fdType=timestamp,期限为指定日期时必填)
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_FD_ESTIMATE_START_TIME, "预计开始时间", contractManage.getStartDate()));
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_FD_ESTIMATE_END_TIME, "预计结束时间", contractManage.getEndDate()));
+ // 【必填】合同开始期限:TMS 固定指定日期起算,日期取 fdEstimateStartTime(v4 合同字段说明)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_START_TERM, "合同开始期限", contractManage.getStartDate() == null ? null : HcsContractFieldConstant.TERM_SPECIFIED_DATE));
+ // 【必填】合同结束期限:TMS 固定指定日期,日期取 fdEstimateEndTime(v4 合同字段说明)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_END_TERM, "合同结束期限", contractManage.getEndDate() == null ? null : HcsContractFieldConstant.TERM_SPECIFIED_DATE));
+ // 【必填】创建时间:createTime(yyyy-MM-dd,fdType=timestamp)
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_CREATE_TIME, "创建时间", contractManage.getCreateTime()));
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_FD_CREATE_TIME, "创建时间", contractManage.getCreateTime()));
+ // 【必填】同步时间:推送时刻(yyyy-MM-dd,fdType=timestamp)
+ fields.add(timestampField(HcsContractFieldConstant.FIELD_FD_SYN_TIME, "同步时间", LocalDateTime.now()));
+ // 【必填】承办人名称:handlerUserName
+ fields.add(field(HcsContractFieldConstant.FIELD_CREATE_USER_NAME, "承办人名称", contractManage.getHandlerUserName()));
+ // 【必填】合同状态:status-mapping[approvalStatus](v4 必填,draft/approving/approved 等,§9-U3 待联调后配置,未配置不传)
+ String contractStatus = hcsProperties.getContract().getStatusMapping().get(contractManage.getApprovalStatus());
+ if (StringUtil.isNotBlank(contractStatus)) {
+ fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_STATUS, "合同状态", contractStatus));
+ } else {
+ log.warn("HCS合同状态映射缺失:approvalStatus=[{}] 不在 contract.status-mapping,CONTRACT_STATUS 不传(§9-U3)", contractManage.getApprovalStatus());
+ }
+ // 【必填】合同份数:copyCount(v4 字段表 fdServings=Integer,数值传值;空值默认 1)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_SERVINGS, "合同份数", contractManage.getCopyCount() == null ? 1 : contractManage.getCopyCount()));
+ // 合同说明:remark(选填)
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_DESC, "合同说明", contractManage.getRemark()));
+ // 关联项目(选填,对象传值):合同所选项目对应合同系统项目ID(blade_project_apply.hcs_fd_id,C102-1 立项同步回填),
+ // 未选择项目或项目未立项同步时不传
+ Map fdProject = queryProjectFdId(contractManage.getProjectId());
+ if (fdProject != null) {
+ fields.add(field(HcsContractFieldConstant.FIELD_FD_PROJECT, "关联项目", fdProject));
+ }
+ // TODO 待联调(v4 选填字段,暂不传):fdCoTakers 协办人(TMS 无对应)、fdUnderTaker/fdUnderTakerDept 承办人及部门(组织架构对象传值方式待确认)、
+ // fdRelations 关联合同(数组)、
+ // fdContractAttribute 合同属性(枚举值文档未给出,TMS 无对应)、fdUrgencyLevel 紧急程度(TMS 无对应)
+ return fields;
+ }
+
+ /**
+ * signer 相对方(签署方):甲/乙双方。fdType 档案优先:客商档案为内部组织(customerKind=internal/内部组织)
+ * 的一方为我方签约主体传 inner,另一方传 customer(外部相对方,2026-10-14 联调口径);
+ * 双方均非内部组织或查不到档案时按合同类别推定(承运商合同甲=inner,客户合同乙=inner,其余 customer);
+ * 丙方(三方合同)TMS 无签约主体字段来源,暂不支持
+ */
+ private List buildSigners(ContractManage contractManage) {
+ List signers = new ArrayList<>();
+ // 甲乙方各反查一次客商档案,编号与内部组织判定共用查询结果
+ CustomerArchive partyAArchive = queryCustomerArchive(contractManage.getPartyA());
+ CustomerArchive partyBArchive = queryCustomerArchive(contractManage.getPartyB());
+ boolean partyAInternal = HcsCreditCheckSupport.isInternalCustomer(partyAArchive);
+ boolean partyBInternal = HcsCreditCheckSupport.isInternalCustomer(partyBArchive);
+ String partyAType;
+ String partyBType;
+ if (partyAInternal || partyBInternal) {
+ // 档案命中:内部组织一方=我方签约主体(inner),外部一方统一 customer
+ partyAType = partyAInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ partyBType = partyBInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ } else if ("承运商合同".equals(contractManage.getContractCategory())) {
+ // 兜底:档案未命中内部组织,按类别推定——我方为付款/发包方,签约主体在甲方
+ partyAType = HcsConstant.SIGNER_FD_TYPE_INNER;
+ partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ } else if ("客户合同".equals(contractManage.getContractCategory())) {
+ // 兜底:我方为收款/承运方,签约主体在乙方
+ partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ partyBType = HcsConstant.SIGNER_FD_TYPE_INNER;
+ } else {
+ // 兜底:其他类别双方统一 customer(2026-10-14 联调口径)
+ partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
+ }
+ // 甲方相对方:fdStanding=甲(编号按名称反查,反查不到留空,§9-U6)
+ signers.add(signer("甲", partyAType, contractManage.getPartyA(), partyAArchive));
+ // 乙方相对方:fdStanding=乙
+ signers.add(signer("乙", partyBType, contractManage.getPartyB(), partyBArchive));
+ return signers;
+ }
+
+ /**
+ * 单个相对方:fdType 由签约主体落位规则决定(我方主体 inner,外部相对方 customer)
+ */
+ private HcsSignerDTO signer(String fdStanding, String fdType, String partyName, CustomerArchive customerArchive) {
+ HcsSignerDTO signer = new HcsSignerDTO();
+ // 签署地位:甲 / 乙
+ signer.setFdStanding(fdStanding);
+ // 相对方名称:TMS 甲乙方名称
+ signer.setFdName(partyName);
+ // 相对方编号:按名称反查 CustomerArchive 客商编码,反查不到留空(§9-U6)
+ signer.setFdNumber(customerArchive == null ? null : customerArchive.getCustomerCode());
+ // 单位类型:inner 内部主体(我方签约主体)/ customer 外部相对方
+ signer.setFdType(fdType);
+ return signer;
+ }
+
+ /**
+ * 附件转传(可配开关):合同主文件 approved=true(挂 eopCdraftSign)+ 其它附件(挂 eopCdraftOther),
+ * 返回合规附件引用列表(挂载 C103-1 顶层 attachment,与 fdFieldJson 同级)
+ */
+ private List transferAttachmentsIfEnabled(ContractManage contractManage) {
+ if (!hcsProperties.getAttachment().isTransferEnabled()) {
+ return List.of();
+ }
+ List attachmentRefs = hcsAttachmentSupport.transferContractAttachments(
+ HcsSyncConstant.BIZ_TYPE_CONTRACT, contractManage.getId(),
+ contractManage.getContractFileJson(), contractManage.getAttachmentsJson(), hcsProperties.getOperatorLoginName());
+ log.info("HCS合同附件转传完成:contractNo={},共 {} 个附件(随 C103-1 挂载)", contractManage.getContractNo(), attachmentRefs.size());
+ return attachmentRefs;
+ }
+
+ /**
+ * 校验 C103-1 返回并取合同 fdContractId(必须持久化)
+ */
+ private String validateDraftResult(ContractManage contractManage, HcsResult result) {
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdContractId())) {
+ log.error("HCS合同发起失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
+ throw new HcsException("C103-1 合同发起失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getFdContractId();
+ }
+
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
+ *
+ * @param id 合同单ID
+ * @return 推送结果描述
+ */
+ public String repushContract(Long id) {
+ ContractManage contractManage = contractManageMapper.selectById(id);
+ if (contractManage == null) {
+ throw new ServiceException("合同单不存在:" + id);
+ }
+ if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的合同可推送合规(当前状态:" + contractManage.getApprovalStatus() + ")");
+ }
+ if (HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())) {
+ throw new ServiceException("合同特批审核中,禁止重推(等待合规特批结果后恢复)");
+ }
+ contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ contractManage.setHcsRetryCount(0);
+ contractManageMapper.updateById(contractManage);
+ try {
+ return pushContract(contractManage);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(contractManage, message);
+ log.error("HCS合同推送失败:{}", message, exception);
+ throw new ServiceException("HCS合同推送失败:" + message);
+ }
+ }
+
+ /**
+ * 回填合规 fdId 并置 synced
+ */
+ public void markSynced(ContractManage contractManage, String fdId, String message) {
+ contractManage.setHcsFdId(fdId);
+ contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ contractManage.setHcsRetryCount(0);
+ contractManage.setHcsSyncTime(LocalDateTime.now());
+ contractManage.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ contractManageMapper.updateById(contractManage);
+ log.info("HCS合同推送成功:contractNo={},fdContractId={},{}", contractManage.getContractNo(), fdId, message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因
+ */
+ public void markFailed(ContractManage contractManage, String message) {
+ contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ contractManage.setHcsRetryCount(contractManage.getHcsRetryCount() == null ? 1 : contractManage.getHcsRetryCount() + 1);
+ contractManage.setHcsSyncTime(LocalDateTime.now());
+ contractManage.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ contractManageMapper.updateById(contractManage);
+ log.error("HCS合同推送失败:contractNo={},retryCount={},{}", contractManage.getContractNo(), contractManage.getHcsRetryCount(), message);
+ }
+
+ /**
+ * 模板映射(template-mapping[contractCategory],未配置/无映射直接失败提示,§9-U7)
+ */
+ private String requireTemplateId(ContractManage contractManage) {
+ Map mapping = hcsProperties.getContract().getTemplateMapping();
+ String templateId = mapping.get(contractManage.getContractCategory());
+ if (StringUtil.isBlank(templateId)) {
+ throw new HcsException("HCS合同推送失败,合同类别 [" + contractManage.getContractCategory()
+ + "] 未配置合规模板ID(thirdParty.hcs.contract.template-mapping,§9-U7)");
+ }
+ return templateId;
+ }
+
+ /**
+ * 按名称反查客商档案(全称或简称精确匹配,查不到返回 null)
+ */
+ private CustomerArchive queryCustomerArchive(String partyName) {
+ if (StringUtil.isBlank(partyName)) {
+ return null;
+ }
+ return customerArchiveMapper.selectOne(Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper.eq(CustomerArchive::getFullName, partyName).or().eq(CustomerArchive::getShortName, partyName))
+ .eq(CustomerArchive::getIsDeleted, 0)
+ .last("limit 1"));
+ }
+
+ /**
+ * 关联项目取值:合同所选项目对应合同系统项目ID(blade_project_apply.hcs_fd_id,C102-1 立项同步回填),
+ * 未选择项目、立项单不存在或未同步合规时返回 null 不传(fdProject 选填)
+ */
+ private Map queryProjectFdId(Long projectId) {
+ if (projectId == null) {
+ return null;
+ }
+ ProjectApply projectApply = projectApplyMapper.selectById(projectId);
+ if (projectApply == null || StringUtil.isBlank(projectApply.getHcsFdId())) {
+ log.warn("HCS合同关联项目缺失合规项目ID:projectId=[{}] 未立项或未同步合规,fdProject 不传", projectId);
+ return null;
+ }
+ return Map.of("fdId", projectApply.getHcsFdId());
+ }
+
+ /**
+ * 币种名称映射:currency-name-mapping 命中取映射值,未命中传原值并告警(§9-U3)
+ */
+ private String mapCurrencyName(String currency) { if (StringUtil.isBlank(currency)) {
+ return null;
+ }
+ return hcsProperties.getContract().getCurrencyNameMapping().getOrDefault(currency, currency);
+ }
+
+ /**
+ * 普通模板字段(字符串/对象传值)
+ */
+ private HcsXformFieldDTO field(String name, String label, Object value) {
+ HcsXformFieldDTO field = new HcsXformFieldDTO();
+ field.setName(name);
+ field.setLabel(label);
+ field.setValue(value);
+ return field;
+ }
+
+ /**
+ * 时间模板字段(fdType=timestamp,v4 要求传 yyyy-MM-dd)
+ */
+ private HcsXformFieldDTO timestampField(String name, String label, Object value) {
+ if (value == null) {
+ return field(name, label, null);
+ }
+ String text;
+ if (value instanceof LocalDate localDate) {
+ text = localDate.format(DATE_FORMATTER);
+ } else if (value instanceof LocalDateTime localDateTime) {
+ text = localDateTime.format(DATE_FORMATTER);
+ } else {
+ // BaseEntity.createTime 为 Date 类型
+ text = DATE_FORMATTER.format(((java.util.Date) value).toInstant()
+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate());
+ }
+ HcsXformFieldDTO field = field(name, label, text);
+ // 时间字段需带 fdType=timestamp
+ field.setFdType("timestamp");
+ return field;
+ }
+
+ /**
+ * 金额字符串(空值安全)
+ */
+ private String toPlainString(BigDecimal amount) {
+ return amount == null ? null : amount.toPlainString();
+ }
+
+ /**
+ * 风险校验留痕(blade_hcs_check_log,异常不影响主流程)
+ */
+ private void writeCheckLog(ContractManage contractManage, String result, String msg, String traceId) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ checkLog.setTenantId(contractManage.getTenantId());
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
+ checkLog.setBizId(contractManage.getId());
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK);
+ checkLog.setResult(result);
+ checkLog.setMsg(msg);
+ checkLog.setTraceId(traceId);
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("HCS风险校验留痕写入失败:contractNo={}", contractManage.getContractNo(), exception);
+ }
+ }
+
+ /**
+ * 单方风险查询结果
+ */
+ private record RiskResult(String blackStatus, String riskLevel, boolean blacklist) {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java
new file mode 100644
index 00000000..2b158e4e
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsCreditCheckSupport.java
@@ -0,0 +1,391 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.jackson.JsonUtil;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsCreditCheckDTO;
+import org.springblade.hcs.dto.HcsCreditCheckVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsRiskQueryDTO;
+import org.springblade.hcs.dto.HcsRiskVO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.CustomerArchiveMapper;
+import org.springblade.transport.mapper.HcsContractCheckLogMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.CustomerArchive;
+import org.springblade.transport.pojo.entity.HcsContractCheckLog;
+import org.springblade.transport.pojo.entity.PaymentApplication;
+import org.springblade.transport.pojo.vo.CustomerRiskVO;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 付款提交校验支撑组件(C101-1 客商风险 + C106-1 额度校验,设计文档 §5.4,D15/R2-Q3)
+ *
+ * 付款申请提交(submit)事务内同步调用(查询类同步):
+ * ① C101-1 收款方为外部客商时查风险,高风险(风险等级=2)/黑名单提示且必须上传「特批附件」,
+ * 并标记 hcs_spec_status=need 转付款特批链路(审批通过后扫描任务发起 C106-3,合规 paymentLegal/result 回调联动);
+ * 内部组织客商默认无风险不查询;
+ * ② C106-1 校验申请金额是否超出合同额度,超额不拦截提交,置 hcs_credit_status=over 并标记
+ * hcs_spec_status=need 转特批链路(D15/R2-Q2)。
+ * 校验依赖合同已推送(hcs_fd_id 存在),未推送时按 credit.require-contract-synced 配置阻断或跳过。
+ * 结果留痕 blade_hcs_check_log(biz_type=payment)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsCreditCheckSupport {
+
+ /**
+ * C101-1 黑名单状态取值(示例 no 表示非黑名单,§9-U3 字典值联调确认)
+ */
+ private static final String BLACK_STATUS_YES = "yes";
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ContractManageMapper contractManageMapper;
+ private final CustomerArchiveMapper customerArchiveMapper;
+ private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
+
+ /**
+ * 付款提交同步校验入口(submit 事务内调用,校验结果字段随提交一并落库)
+ *
+ * @param payment 付款申请单(须含合同与收款方信息)
+ */
+ public void checkOnSubmit(PaymentApplication payment) {
+ // ① 客商风险校验(可配开关):外部客商高风险/黑名单须特批附件并标记待特批
+ if (hcsProperties.getRisk().isCheckOnPayment()) {
+ checkRelativeRisk(payment);
+ }
+ // ② 额度校验(超额不拦截,转特批);一期暂不对接默认关,关闭后特批无自动触发
+ if (!hcsProperties.getCredit().isCheckEnabled()) {
+ return;
+ }
+ checkCredit(payment);
+ }
+
+ /**
+ * C101-1 收款方客商风险校验:仅外部客商查询,高风险/黑名单必须上传特批附件,
+ * 并标记 hcs_spec_status=need 转付款特批链路(不再直接阻断)
+ */
+ private void checkRelativeRisk(PaymentApplication payment) {
+ CustomerArchive customerArchive = queryCustomerArchive(payment.getPayeeName());
+ if (customerArchive == null) {
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_RISK, HcsSyncConstant.CHECK_RESULT_SKIP,
+ "收款方[" + payment.getPayeeName() + "]反查不到客商编号,跳过风险校验(§9-U6)", null);
+ log.warn("HCS付款提交风险校验跳过:paymentNo={},收款方反查不到客商编号", payment.getPaymentNo());
+ return;
+ }
+ // 内部组织类型客商默认无风险,不查 C101-1(与 queryCustomerRisk 展示口径一致)
+ if (isInternalCustomer(customerArchive)) {
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_RISK, HcsSyncConstant.CHECK_RESULT_SKIP,
+ "收款方[" + payment.getPayeeName() + "]为内部组织客商,默认无风险跳过校验", null);
+ return;
+ }
+ HcsRiskQueryDTO param = new HcsRiskQueryDTO();
+ // 【必填】客商编号:按收款方名称反查 CustomerArchive 客商编码(用哪个编码待联调,§9-U6)
+ param.setFdNumber(customerArchive.getCustomerCode());
+ HcsResult result = hcsClient.getRelativeRisk(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS客商风险查询失败响应:{},paymentNo={},fdNumber={}",
+ result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo(), customerArchive.getCustomerCode());
+ throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsRiskVO risk = result.getData();
+ String blackStatus = risk == null ? null : risk.getFdBlackStatus();
+ String riskLevel = risk == null ? null : risk.getFdRiskLevel();
+ boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_RISK, risky ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS,
+ "收款方[" + payment.getPayeeName() + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel + ",高风险/黑名单=" + risky,
+ result.getTraceId());
+ if (!risky) {
+ return;
+ }
+ // 高风险/黑名单:必须上传「特批附件」类型附件(提示阻断),并标记待特批(审批通过后推送 C106-3);
+ // 清空旧特批流程ID:特批单次有效,重新提交须重新发起 C106-3(否则会 C106-4 更新旧驳回单)
+ if (!hasSpecAttachment(payment.getAttachmentsJson())) {
+ throw new ServiceException("收款方[" + payment.getPayeeName() + "]为高风险/黑名单客商,必须上传「特批附件」类型附件后再提交审批");
+ }
+ payment.setHcsSpecFdId(null);
+ payment.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_NEED);
+ }
+
+ /**
+ * C106-1 额度校验:通过置 pass;超额置 over 并标记待特批(不拦截提交,D15/R2-Q2)
+ */
+ private void checkCredit(PaymentApplication payment) {
+ ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId());
+ if (contractManage == null || StringUtil.isBlank(contractManage.getHcsFdId())) {
+ if (hcsProperties.getCredit().isRequireContractSynced()) {
+ throw new ServiceException("关联合同尚未推送合规,无法校验额度(paymentNo=" + payment.getPaymentNo() + ")");
+ }
+ log.warn("HCS付款提交额度校验跳过:paymentNo={},合同未推送合规(require-contract-synced=false)", payment.getPaymentNo());
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_CREDIT, HcsSyncConstant.CHECK_RESULT_SKIP, "合同未推送合规,跳过额度校验", null);
+ return;
+ }
+ HcsCreditCheckDTO param = new HcsCreditCheckDTO();
+ // 【必填】合同ID:HCS 合同ID(blade_contract_manage.hcs_fd_id)
+ param.setFdContractId(contractManage.getHcsFdId());
+ // 【必填】申请金额:PaymentApplication.appliedAmount(元)
+ param.setFdApplyAmount(payment.getAppliedAmount() == null ? BigDecimal.ZERO : payment.getAppliedAmount());
+ HcsResult result = hcsClient.contractCreditCheck(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS额度校验失败响应:{},paymentNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo());
+ throw new HcsException("C106-1 额度校验失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsCreditCheckVO credit = result.getData();
+ payment.setHcsCreditTime(LocalDateTime.now());
+ if (credit != null && Boolean.TRUE.equals(credit.getFdIsCredit())) {
+ // 额度通过:正常提交
+ payment.setHcsCreditStatus(HcsSyncConstant.CREDIT_STATUS_PASS);
+ payment.setHcsCreditMsg(null);
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_CREDIT, HcsSyncConstant.CHECK_RESULT_PASS, "额度校验通过", result.getTraceId());
+ return;
+ }
+ // 超额:success 仍为 true,必须以 fdIsCredit 为准;不拦截提交,转入特批链路
+ String message = credit == null ? "响应数据为空" : credit.getFdMessage();
+ payment.setHcsCreditStatus(HcsSyncConstant.CREDIT_STATUS_OVER);
+ payment.setHcsCreditMsg(message);
+ payment.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_NEED);
+ writeCheckLog(payment, HcsSyncConstant.CHECK_TYPE_CREDIT, HcsSyncConstant.CHECK_RESULT_FAIL, "额度超额:" + message, result.getTraceId());
+ log.warn("HCS付款提交额度超额(转特批):paymentNo={},{}", payment.getPaymentNo(), message);
+ }
+
+ /**
+ * 客商风险查询(C101-1,展示与提交校验共用,前端接入设计 §3.3):
+ * 内部组织类型客商默认无风险(不调 C101-1);外部客商按编号查询风险等级与黑名单状态并留痕;
+ * 高风险/黑名单时组装风险提示文案(风险项明细 C101-1 未返回,固定文案展示)
+ *
+ * @param customerCode 客商编号
+ * @return 风险展示信息(customerKind/fdBlackStatus/fdRiskLevel/risky/riskTip)
+ */
+ public CustomerRiskVO queryCustomerRisk(String customerCode) {
+ if (StringUtil.isBlank(customerCode)) {
+ throw new ServiceException("客商编号不能为空");
+ }
+ CustomerArchive customerArchive = customerArchiveMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CustomerArchive::getCustomerCode, customerCode)
+ .eq(CustomerArchive::getIsDeleted, 0)
+ .last("limit 1"));
+ CustomerRiskVO riskVO = new CustomerRiskVO();
+ riskVO.setCustomerCode(customerCode);
+ riskVO.setCustomerName(customerArchive == null ? customerCode : customerArchive.getFullName());
+ // 内部组织类型客商默认无风险;档案不存在按外部客商查询(不静默放行)
+ boolean internal = isInternalCustomer(customerArchive);
+ riskVO.setCustomerKind(internal ? HcsSyncConstant.CUSTOMER_KIND_INTERNAL : "external");
+ if (internal) {
+ riskVO.setRisky(Boolean.FALSE);
+ return riskVO;
+ }
+ HcsRiskQueryDTO param = new HcsRiskQueryDTO();
+ // 【必填】客商编号(用哪个编码待联调,§9-U6)
+ param.setFdNumber(customerCode);
+ HcsResult result = hcsClient.getRelativeRisk(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS客商风险查询失败响应:{},fdNumber={}",
+ result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), customerCode);
+ throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsRiskVO risk = result.getData();
+ String blackStatus = risk == null ? null : risk.getFdBlackStatus();
+ String riskLevel = risk == null ? null : risk.getFdRiskLevel();
+ boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
+ riskVO.setFdBlackStatus(blackStatus);
+ riskVO.setFdRiskLevel(riskLevel);
+ riskVO.setRiskLevelName(riskLevelName(blackStatus, riskLevel));
+ riskVO.setRisky(risky);
+ if (risky) {
+ // 风险提示固定短文案(C101-1 未返回风险项明细,不拼接枚举清单):黑名单与高风险区分表述
+ riskVO.setRiskTip("【" + riskVO.getCustomerName() + "】:"
+ + (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus) ? "被列入外部黑名单" : "为高风险客商"));
+ }
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK);
+ checkLog.setResult(risky ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS);
+ checkLog.setMsg("客商风险查询[" + customerCode + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel + ",高风险/黑名单=" + risky);
+ checkLog.setTraceId(result.getTraceId());
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception);
+ }
+ return riskVO;
+ }
+
+ /**
+ * 按客商名称查询风险(合同提交校验用):按名称反查客商编号后转 {@link #queryCustomerRisk};
+ * 反查不到客商编号时不阻断(risky=false),与推送链路 skip 口径一致(§9-U6)
+ *
+ * @param customerName 客商名称(合同甲/乙方名称)
+ * @return 风险展示信息
+ */
+ public CustomerRiskVO queryCustomerRiskByName(String customerName) {
+ String customerCode = queryCustomerCode(customerName);
+ if (StringUtil.isBlank(customerCode)) {
+ CustomerRiskVO riskVO = new CustomerRiskVO();
+ riskVO.setCustomerName(customerName);
+ riskVO.setRisky(Boolean.FALSE);
+ return riskVO;
+ }
+ return queryCustomerRisk(customerCode);
+ }
+
+ /**
+ * 内部组织客商判定(集团内我方签约主体,档案登记 customerKind=internal/内部组织);
+ * 展示、提交校验、付款校验、合同推送、signer 单位类型各链路统一复用本判定
+ *
+ * @param customerArchive 客商档案
+ * @return 是否内部组织客商
+ */
+ public static boolean isInternalCustomer(CustomerArchive customerArchive) {
+ return customerArchive != null
+ && (HcsSyncConstant.CUSTOMER_KIND_INTERNAL.equals(customerArchive.getCustomerKind())
+ || "内部组织".equals(customerArchive.getCustomerKind()));
+ }
+
+ /**
+ * 风险等级转义(C101-1 枚举 0=低风险/1=中风险/2=高风险):黑名单状态优先展示,无等级返回空
+ */
+ private String riskLevelName(String blackStatus, String riskLevel) {
+ if (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus)) {
+ return "黑名单";
+ }
+ return switch (Objects.toString(riskLevel, "")) {
+ case "2" -> "高风险";
+ case "1" -> "中风险";
+ case "0" -> "低风险";
+ default -> null;
+ };
+ }
+
+ /**
+ * 客商风险批量查询(C101-1,客商列表页展示用,前端接入设计 §3.3):
+ * 逐个复用 {@link #queryCustomerRisk}(内部客商默认无风险,外部客商查 C101-1,与付款页面展示口径一致);
+ * C101-1 仅支持单编号查询,逐个串行调用;展示场景单个失败跳过不阻断整页(强制校验仍在合同/付款链路)
+ *
+ * @param customerCodes 客商编号集合
+ * @return 风险展示信息列表(查询失败的客商不在结果中)
+ */
+ public List queryCustomerRiskBatch(List customerCodes) {
+ List riskList = new ArrayList<>();
+ if (Func.isEmpty(customerCodes)) {
+ return riskList;
+ }
+ for (String customerCode : customerCodes) {
+ try {
+ riskList.add(this.queryCustomerRisk(customerCode));
+ } catch (Exception exception) {
+ log.error("客商风险批量查询单个失败,跳过:customerCode={}", customerCode, exception);
+ }
+ }
+ return riskList;
+ }
+
+ /**
+ * 按名称反查客商档案(内部/外部类型判断与客商编码获取共用;查不到返回 null)
+ */
+ private CustomerArchive queryCustomerArchive(String customerName) {
+ if (StringUtil.isBlank(customerName)) {
+ return null;
+ }
+ return customerArchiveMapper.selectOne(Wrappers.lambdaQuery()
+ .and(wrapper -> wrapper.eq(CustomerArchive::getFullName, customerName).or().eq(CustomerArchive::getShortName, customerName))
+ .eq(CustomerArchive::getIsDeleted, 0)
+ .last("limit 1"));
+ }
+
+ /**
+ * 按名称反查客商编码(照 KingdeeApBillSyncSupport#queryCustomerCode 惯例,查不到返回 null)
+ */
+ private String queryCustomerCode(String customerName) {
+ CustomerArchive customerArchive = queryCustomerArchive(customerName);
+ return customerArchive == null ? null : customerArchive.getCustomerCode();
+ }
+
+ /**
+ * 附件中是否已上传特批附件(付款附件结构 attachmentType=special_approval,即前端付款表单「特批附件」类型;
+ * 兼容 fileType=特批附件 结构;口径同合同提交校验 validateSpecExplanationOnSubmit)
+ */
+ private boolean hasSpecAttachment(String attachmentsJson) {
+ if (StringUtil.isBlank(attachmentsJson)) {
+ return false;
+ }
+ try {
+ Object parsed = JsonUtil.parse(attachmentsJson, List.class);
+ if (!(parsed instanceof List> attachments)) {
+ return false;
+ }
+ return attachments.stream().filter(Map.class::isInstance).map(Map.class::cast)
+ .anyMatch(attachment -> HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPECIAL
+ .equals(Objects.toString(attachment.get("attachmentType"), null))
+ || HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION
+ .equals(Objects.toString(attachment.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD), null)));
+ } catch (Exception exception) {
+ log.error("HCS付款提交附件JSON解析失败:按未上传特批附件处理", exception);
+ return false;
+ }
+ }
+
+ /**
+ * 校验留痕(blade_hcs_check_log,异常不影响主流程)
+ */
+ private void writeCheckLog(PaymentApplication payment, String checkType, String result, String msg, String traceId) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ checkLog.setTenantId(payment.getTenantId());
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_PAYMENT);
+ checkLog.setBizId(payment.getId());
+ checkLog.setCheckType(checkType);
+ checkLog.setResult(result);
+ checkLog.setMsg(msg);
+ checkLog.setTraceId(traceId);
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("HCS付款校验留痕写入失败:paymentNo={}", payment.getPaymentNo(), exception);
+ }
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java
new file mode 100644
index 00000000..89bed20c
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java
@@ -0,0 +1,255 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsExeplanStageDTO;
+import org.springblade.hcs.dto.HcsExeplanStageVO;
+import org.springblade.hcs.dto.HcsExeplanSyncDTO;
+import org.springblade.hcs.dto.HcsExeplanVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.HcsContractExeStageMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.HcsContractExeStage;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 履约规则同步支撑组件(C103-9 新版行级同步,设计文档 §5.3,ADR-0002)
+ *
+ * 随合同推送链路执行(合同须已同步、hcs_fd_id 存在):聚合 row_op 非 none 的规则行
+ * (add 行不带 fdId,update/delete 行必须带合规阶段ID),整体 fdEopExeplanOperateType
+ * 首次为 add、后续为 update;返回的 fdExeplanId 回填 blade_contract_manage.hcs_exeplan_id,
+ * 变更阶段 fdId 按行回填 hcs_stage_fd_id(合规返回的 fdId 必须持久化,后续行更新/删除全靠它)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsExeStageSyncSupport {
+
+ /**
+ * 行级扩展内容固定空 JSON
+ */
+ private static final String EXTEND_CONTENT_EMPTY = "{}";
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsContractExeStageMapper hcsContractExeStageMapper;
+
+ /**
+ * 同步合同履约规则到 HCS(C103-9),成功后回填 fdExeplanId 与各阶段 fdId 并清除行级操作标记
+ *
+ * @param contractManage 合同(须已同步,hcs_fd_id 非空)
+ * @return 同步结果描述(无待同步行时返回提示)
+ */
+ public String syncExeStages(ContractManage contractManage) {
+ if (StringUtil.isBlank(contractManage.getHcsFdId())) {
+ throw new HcsException("C103-9 履约规则同步失败,合同尚未推送合规(contractNo=" + contractManage.getContractNo() + ")");
+ }
+ // 含软删行:delete 行已逻辑删除但须凭合规阶段ID 上报(原生 SQL 绕过逻辑删除过滤)
+ List pendingRows = hcsContractExeStageMapper.selectSyncRows(contractManage.getId());
+ if (pendingRows.isEmpty()) {
+ return "履约规则无待同步行";
+ }
+ HcsExeplanSyncDTO param = buildSyncParam(contractManage, pendingRows);
+ log.info("HCS履约规则同步开始:contractNo={},整体操作 {},{} 行", contractManage.getContractNo(),
+ param.getFdEopExeplanOperateType(), pendingRows.size());
+ HcsResult result = hcsClient.exeplanAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null) {
+ log.error("HCS履约规则同步失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
+ throw new HcsException("C103-9 履约规则同步失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsExeplanVO exeplan = result.getData();
+ backfill(contractManage, pendingRows, param.getFdStages(), exeplan);
+ return String.format("履约规则同步成功(%s,%d 行)", param.getFdEopExeplanOperateType(), pendingRows.size());
+ }
+
+ /**
+ * 组装 C103-9 入参(字段标注照 §3.4 规范,映射见设计文档 §5.3)
+ */
+ private HcsExeplanSyncDTO buildSyncParam(ContractManage contractManage, List pendingRows) {
+ HcsExeplanSyncDTO param = new HcsExeplanSyncDTO();
+ // 【必填】合同ID:HCS 合同ID(blade_contract_manage.hcs_fd_id,非 TMS 主键)
+ param.setFdContractId(contractManage.getHcsFdId());
+ // 【必填】外部系统标识:fd_sources,配置 thirdParty.hcs.src-system(D14)
+ param.setFdOuterSystem(hcsProperties.getSrcSystem());
+ // 【必填】操作人登录名:配置 thirdParty.hcs.operator-login-name(默认 srvadmin,§9-U2)
+ param.setFdLoginName(hcsProperties.getOperatorLoginName());
+ // 【必填】计划整体操作类型:首次同步 add,已同步过(有履约计划ID)update
+ param.setFdEopExeplanOperateType(StringUtil.isBlank(contractManage.getHcsExeplanId()) ? "add" : "update");
+ // 履约计划ID:已同步过则传(回填自首次同步返回)
+ param.setFdExeplanId(contractManage.getHcsExeplanId());
+ List stages = new ArrayList<>();
+ for (HcsContractExeStage row : pendingRows) {
+ HcsExeplanStageDTO stage = new HcsExeplanStageDTO();
+ // 阶段项ID:行级 update/delete 必传(取 hcs_stage_fd_id),add 行不带
+ if (!"add".equals(row.getRowOp())) {
+ stage.setFdId(row.getHcsStageFdId());
+ }
+ // 【必填】行级操作:add / update / delete
+ stage.setFdEopExeplanOperateType(row.getRowOp());
+ // 【必填】履约类型:payment=付款 / collection=收款
+ stage.setFdPerformType(row.getPerformType());
+ // 【必填】款项性质:prepayment/progress/acceptance/retention
+ stage.setFdFundNature(row.getFundNature());
+ // 【必填】款项金额(元,付款审批通过按申请付款金额累加更新)
+ stage.setFdFundAmount(row.getFundAmount());
+ // 【必填】资金日期(毫秒时间戳)
+ stage.setFdFundDate(toMillis(row.getFundDate()));
+ // 【必填】开票规则:payFirst=先款后票 / invoiceFirst=先票后款
+ stage.setFdInvoiceRule(row.getInvoiceRule());
+ // 【必填】账期(天)
+ stage.setFdInvoiceCycle(row.getInvoiceCycle());
+ // 金额类型:行维护值,空取配置 thirdParty.hcs.exe-stage.fund-type(默认 byAmount)
+ stage.setFdFundType(StringUtil.isBlank(row.getFundType()) ? hcsProperties.getExeStage().getFundType() : row.getFundType());
+ // 是否完成
+ stage.setFdIsFinish(row.getIsFinish() != null && row.getIsFinish() > 0);
+ // 扩展内容 JSON 字符串:固定 "{}"
+ stage.setFdExtendContent(EXTEND_CONTENT_EMPTY);
+ stages.add(stage);
+ }
+ param.setFdStages(stages);
+ return param;
+ }
+
+ /**
+ * 回填:fdExeplanId → 合同 hcs_exeplan_id;变更阶段 fdId 按行回填 hcs_stage_fd_id 并清除行级操作标记
+ *
+ * TODO 待联调确认:fdChangeStages 与请求行的对应关系 v4 未明确,优先按请求行序对应(首次全 add 场景),
+ * 行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。
+ */
+ private void backfill(ContractManage contractManage, List pendingRows,
+ List requestStages, HcsExeplanVO exeplan) {
+ // 履约计划ID 回填(后续行更新/删除依赖,必须持久化)
+ if (StringUtil.isNotBlank(exeplan.getFdExeplanId())
+ && !exeplan.getFdExeplanId().equals(contractManage.getHcsExeplanId())) {
+ contractManage.setHcsExeplanId(exeplan.getFdExeplanId());
+ }
+ backfillExeplanId(contractManage);
+ List changeStages = exeplan.getFdChangeStages() == null ? List.of() : exeplan.getFdChangeStages();
+ // 行数一致时按请求行序一一对应;不一致时按业务属性组合键匹配
+ boolean positional = changeStages.size() == requestStages.size();
+ Map byAttribute = positional ? Map.of() : mapByAttribute(changeStages);
+ int addRowCount = 0;
+ int unmatchedCount = 0;
+ for (int index = 0; index < pendingRows.size(); index++) {
+ HcsContractExeStage row = pendingRows.get(index);
+ String stageFdId = row.getHcsStageFdId();
+ if ("add".equals(row.getRowOp())) {
+ addRowCount++;
+ HcsExeplanStageVO matched = positional ? changeStages.get(index) : byAttribute.get(attributeKey(requestStages.get(index)));
+ stageFdId = matched == null ? null : matched.getFdId();
+ if (StringUtil.isBlank(stageFdId)) {
+ unmatchedCount++;
+ }
+ }
+ markRowSynced(row, stageFdId);
+ }
+ log.info("HCS履约规则阶段ID回填完成:contractNo={},add {} 行,未匹配 {} 行(模式:{})",
+ contractManage.getContractNo(), addRowCount, unmatchedCount, positional ? "行序对应" : "属性匹配");
+ }
+
+ /**
+ * 行同步成功回写:回填合规阶段ID、清除行级操作标记并置 synced
+ */
+ private void markRowSynced(HcsContractExeStage row, String stageFdId) {
+ if (StringUtil.isNotBlank(stageFdId)) {
+ row.setHcsStageFdId(stageFdId);
+ } else if ("add".equals(row.getRowOp())) {
+ // add 行未取得阶段ID:保持待同步,下轮重试(ADR-0002:阶段ID 必须持久化)
+ row.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ row.setHcsSyncMsg("返回阶段ID未能匹配,待重试");
+ hcsContractExeStageMapper.updateById(row);
+ log.warn("HCS履约规则 add 行未匹配到返回阶段ID:rowId={},contractId={}", row.getId(), row.getContractId());
+ return;
+ }
+ row.setRowOp("none");
+ row.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ row.setHcsSyncMsg("同步成功");
+ hcsContractExeStageMapper.updateById(row);
+ }
+
+ /**
+ * 按业务属性组合键索引返回阶段(行数不一致时的兜底匹配)
+ */
+ private Map mapByAttribute(List changeStages) {
+ Map byAttribute = new HashMap<>();
+ for (HcsExeplanStageVO stage : changeStages) {
+ byAttribute.putIfAbsent(String.join("|", Objects.toString(stage.getFdPerformType(), ""),
+ Objects.toString(stage.getFdFundNature(), ""),
+ Objects.toString(stage.getFdFundAmount(), ""),
+ Objects.toString(stage.getFdFundDate(), "")), stage);
+ }
+ return byAttribute;
+ }
+
+ /**
+ * 请求行的业务属性组合键
+ */
+ private String attributeKey(HcsExeplanStageDTO stage) {
+ return String.join("|", Objects.toString(stage.getFdPerformType(), ""),
+ Objects.toString(stage.getFdFundNature(), ""),
+ Objects.toString(stage.getFdFundAmount(), ""),
+ Objects.toString(stage.getFdFundDate(), ""));
+ }
+
+ /**
+ * 合同履约计划ID 回写(fdExeplanId 必须持久化;失败仅告警不阻断行回填,下轮同步会再次携带)
+ */
+ private void backfillExeplanId(ContractManage contractManage) {
+ try {
+ contractManageMapper.updateById(contractManage);
+ log.info("HCS履约计划ID已回填:contractNo={},fdExeplanId={}", contractManage.getContractNo(), contractManage.getHcsExeplanId());
+ } catch (Exception exception) {
+ log.error("HCS履约计划ID回写失败:contractNo={}", contractManage.getContractNo(), exception);
+ }
+ }
+
+ /**
+ * LocalDateTime → 毫秒时间戳
+ */
+ private Long toMillis(LocalDateTime localDateTime) {
+ return localDateTime == null ? null : localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java
new file mode 100644
index 00000000..cc0e6106
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsImmediatePushSupport.java
@@ -0,0 +1,300 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.redis.cache.BladeRedis;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.PaymentApplicationMapper;
+import org.springblade.transport.mapper.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.PaymentApplication;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * HCS 审批通过即时推送(前端接入设计 §6,2026-10-10 增补)
+ *
+ * 项目立项(C102)/合同(C103)/付款申请(C106 付款特批)审批通过(含变更通过)事务提交后立即触发单条推送,不再等待扫描周期;
+ * 执行体复用 {@link HcsProjectSyncSupport#repushProject(Long)} / {@link HcsContractSyncSupport#repushContract(Long)} /
+ * {@link HcsPaymentSyncSupport#repushPayment(Long)}
+ * (已含审批状态校验、失败 markFailed、高风险特批挂起判断),失败仅留痕,由 HcsPushTask 扫描兜底补偿
+ * (ADR-0001 修订:审批代码埋点为一行触发调用,推送逻辑仍收敛在 hcs support 包)。
+ *
+ * 触发模型:审批事务内调用则注册 afterCommit 回调(事务回滚不推送),无活动事务(MK 回调单条更新自动提交)直接提交;
+ * 自建 2 线程 daemon 池(工程未启用 @EnableAsync),HCS 推送可达 20s+ 不阻塞审批响应;
+ * Redis 单条锁防与扫描任务并发双推(TTL 120s,持有值比对释放,照 HcsPushTask 惯例),
+ * 极端双推由 HCS 侧判重/更新幂等吸收。与扫描任务同线程模型(无租户上下文),行为一致。
+ *
+ * 例外:TMS 合同/项目立项的"通过"按钮路径由 Controller 在审批事务返回后分别调用
+ * {@link #pushContractAndWait(Long)}、{@link #pushProjectAndWait(Long)} 同步推送
+ * (审批响应会等待附件转传 + HCS 调用完成,与正式结算单审批一致),
+ * 失败原因经 {@link #pollSyncFailure()} 交给 Controller 拼进审批响应(照 KingdeeApprovalSyncListener);
+ * MK 回调路径仍异步,避免阻塞第三方回调。
+ *
+ * 付款申请按"审批即发起特批"要求:即时推送不受 thirdParty.hcs.push.immediate-enabled 开关控制
+ * ({@link #pushPaymentAndWait(Long)} / {@link #pushPaymentAfterCommit(Long)}),
+ * 审批通过即以 C106-3 发起特批,履约计划与履约记录等特批通过后补推。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsImmediatePushSupport {
+
+ /**
+ * 审批通过状态集合(与 HcsPushTask.APPROVED_STATUSES 一致,含变更通过)
+ */
+ private static final List APPROVED_STATUSES = List.of("approved", "change_approved");
+
+ /**
+ * 推送线程编号(线程名 hcs-immediate-push-N 便于日志定位)
+ */
+ private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(1);
+
+ /**
+ * 当前审批请求线程的合规推送失败原因,供审批 Controller 取走提示前端
+ */
+ private static final ThreadLocal SYNC_FAILURE = new ThreadLocal<>();
+
+ private final HcsProperties hcsProperties;
+ private final HcsProjectSyncSupport hcsProjectSyncSupport;
+ private final HcsContractSyncSupport hcsContractSyncSupport;
+ private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
+ private final ProjectApplyMapper projectApplyMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final PaymentApplicationMapper paymentApplicationMapper;
+ private final BladeRedis bladeRedis;
+
+ /**
+ * 推送线程池:2 线程 daemon,即时推送与定时扫描互不影响
+ */
+ private final ExecutorService pushExecutor = Executors.newFixedThreadPool(2, task -> {
+ Thread thread = new Thread(task, "hcs-immediate-push-" + THREAD_COUNTER.getAndIncrement());
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ /**
+ * 项目立项(C102)审批通过后即时推送:须在审批事务内调用(afterCommit 触发)
+ *
+ * @param id 项目立项单ID
+ */
+ public void pushProjectAfterCommit(Long id) {
+ this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_PROJECT, id, () -> this.pushProjectNow(id), true);
+ }
+
+ /**
+ * 合同(C103)审批通过后即时推送:须在审批事务内调用(afterCommit 触发)
+ *
+ * @param id 合同单ID
+ */
+ public void pushContractAfterCommit(Long id) {
+ this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id), true);
+ }
+
+ /**
+ * 付款申请审批通过后即时发起付款特批(C106-3,无需特批则直接推履约数据):
+ * 须在审批事务内调用(afterCommit 触发);按"审批即发起特批"要求不受 immediate-enabled 开关控制
+ *
+ * @param id 付款申请单ID
+ */
+ public void pushPaymentAfterCommit(Long id) {
+ this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_PAYMENT, id, () -> this.pushPaymentNow(id), false);
+ }
+
+ /**
+ * 合同"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走
+ *
+ * @param id 合同单ID
+ */
+ public void pushContractAndWait(Long id) {
+ if (!hcsProperties.getPush().isImmediateEnabled()) {
+ return;
+ }
+ this.pushWithLock(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id));
+ }
+
+ /**
+ * 项目立项"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走
+ *
+ * @param id 项目立项单ID
+ */
+ public void pushProjectAndWait(Long id) {
+ if (!hcsProperties.getPush().isImmediateEnabled()) {
+ return;
+ }
+ this.pushWithLock(HcsSyncConstant.BIZ_TYPE_PROJECT, id, () -> this.pushProjectNow(id));
+ }
+
+ /**
+ * 付款申请"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走;
+ * 按"审批即发起特批"要求不受 immediate-enabled 开关控制
+ *
+ * @param id 付款申请单ID
+ */
+ public void pushPaymentAndWait(Long id) {
+ this.pushWithLock(HcsSyncConstant.BIZ_TYPE_PAYMENT, id, () -> this.pushPaymentNow(id));
+ }
+
+ /**
+ * 取走并清除当前审批请求的合规推送失败原因;null 表示推送成功、未触发或仍异步进行
+ */
+ public static String pollSyncFailure() {
+ String failure = SYNC_FAILURE.get();
+ SYNC_FAILURE.remove();
+ return failure;
+ }
+
+ /**
+ * 提交时机注册:有活动事务注册 afterCommit 回调(回滚不推送),无事务(MK 回调单条更新自动提交)直接异步
+ *
+ * @param requireSwitch 是否受 immediate-enabled 开关控制(项目/合同是,付款特批按"审批即发起"否)
+ */
+ private void submitAfterCommit(String bizType, Long id, Runnable pushAction, boolean requireSwitch) {
+ if (requireSwitch && !hcsProperties.getPush().isImmediateEnabled()) {
+ return;
+ }
+ Runnable pushTask = () -> this.pushWithLock(bizType, id, pushAction);
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ pushExecutor.execute(pushTask);
+ }
+ });
+ } else {
+ pushExecutor.execute(pushTask);
+ }
+ }
+
+ /**
+ * 单条锁防并发双推:抢不到即放弃(扫描兜底),持有值比对后释放
+ */
+ private void pushWithLock(String bizType, Long id, Runnable pushAction) {
+ String lockKey = HcsSyncConstant.PUSH_SINGLE_LOCK_KEY_PREFIX + bizType + ":" + id;
+ String lockHolder = UUID.randomUUID().toString();
+ // 清理同线程残留的失败原因,避免线程复用串单(同步推送路径会写入)
+ SYNC_FAILURE.remove();
+ Boolean locked = bladeRedis.getValueOps()
+ .setIfAbsent(lockKey, lockHolder, Duration.ofSeconds(HcsSyncConstant.PUSH_SINGLE_LOCK_TTL_SECONDS));
+ if (!Boolean.TRUE.equals(locked)) {
+ log.info("HCS即时推送跳过(其他线程推送中):bizType={},id={}", bizType, id);
+ return;
+ }
+ try {
+ pushAction.run();
+ } finally {
+ if (lockHolder.equals(bladeRedis.get(lockKey))) {
+ bladeRedis.del(lockKey);
+ }
+ }
+ }
+
+ /**
+ * 项目立项推送执行体:非审批通过/已同步短路跳过,失败已由 Support markFailed,仅留痕交扫描补偿
+ */
+ private void pushProjectNow(Long id) {
+ try {
+ ProjectApply projectApply = projectApplyMapper.selectById(id);
+ if (projectApply == null || !APPROVED_STATUSES.contains(projectApply.getApprovalStatus())
+ || HcsSyncConstant.HCS_STATUS_SYNCED.equals(projectApply.getHcsSyncStatus())) {
+ return;
+ }
+ hcsProjectSyncSupport.repushProject(id);
+ } catch (Exception exception) {
+ log.error("HCS项目立项即时推送失败(扫描任务将兜底补偿):id={},{}", id, exception.getMessage(), exception);
+ // 失败原因留在当前线程供审批 Controller 取走;异步路径无人取走,仅影响线程池自身
+ String reason = exception.getMessage() == null ? "合规接口调用异常" : exception.getMessage();
+ SYNC_FAILURE.set(reason.startsWith("HCS项目立项推送失败:")
+ ? reason.substring("HCS项目立项推送失败:".length()) : reason);
+ }
+ }
+
+ /**
+ * 合同推送执行体:非审批通过/已同步/特批挂起中短路跳过,失败已由 Support markFailed,仅留痕交扫描补偿
+ */
+ private void pushContractNow(Long id) {
+ try {
+ ContractManage contractManage = contractManageMapper.selectById(id);
+ if (contractManage == null || !APPROVED_STATUSES.contains(contractManage.getApprovalStatus())
+ || HcsSyncConstant.HCS_STATUS_SYNCED.equals(contractManage.getHcsSyncStatus())) {
+ return;
+ }
+ if (Arrays.asList(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(contractManage.getHcsSpecStatus())) {
+ log.info("HCS合同即时推送跳过(特批挂起中):id={},hcsSpecStatus={}", id, contractManage.getHcsSpecStatus());
+ return;
+ }
+ hcsContractSyncSupport.repushContract(id);
+ } catch (Exception exception) {
+ log.error("HCS合同即时推送失败(扫描任务将兜底补偿):id={},{}", id, exception.getMessage(), exception);
+ // 失败原因留在当前线程供审批 Controller 取走;异步路径无人取走,仅影响线程池自身
+ String reason = exception.getMessage() == null ? "合规接口调用异常" : exception.getMessage();
+ SYNC_FAILURE.set(reason.startsWith("HCS合同推送失败:")
+ ? reason.substring("HCS合同推送失败:".length()) : reason);
+ }
+ }
+
+ /**
+ * 付款推送执行体:非审批通过/已同步短路跳过;失败已由 Support markFailed,仅留痕交扫描/手动重推补偿
+ */
+ private void pushPaymentNow(Long id) {
+ try {
+ PaymentApplication payment = paymentApplicationMapper.selectById(id);
+ if (payment == null || !APPROVED_STATUSES.contains(payment.getApprovalStatus())
+ || HcsSyncConstant.HCS_STATUS_SYNCED.equals(payment.getHcsSyncStatus())) {
+ return;
+ }
+ hcsPaymentSyncSupport.repushPayment(id);
+ } catch (Exception exception) {
+ log.error("HCS付款即时推送失败(可手动重推或扫描任务补偿):id={},{}", id, exception.getMessage(), exception);
+ // 失败原因留在当前线程供审批 Controller 取走;异步路径无人取走,仅影响线程池自身
+ String reason = exception.getMessage() == null ? "合规接口调用异常" : exception.getMessage();
+ SYNC_FAILURE.set(reason.startsWith("HCS付款申请推送失败:")
+ ? reason.substring("HCS付款申请推送失败:".length()) : reason);
+ }
+ }
+
+ @PreDestroy
+ public void shutdown() {
+ pushExecutor.shutdown();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoiceRegSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoiceRegSyncSupport.java
new file mode 100644
index 00000000..067ab47b
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsInvoiceRegSyncSupport.java
@@ -0,0 +1,361 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsIdVO;
+import org.springblade.hcs.dto.HcsInvoiceRegAddDTO;
+import org.springblade.hcs.dto.HcsInvoiceRegUpdateDTO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsInvoiceRegVO;
+import org.springblade.hcs.dto.HcsRefVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.KingdeeBfiConstant;
+import org.springblade.system.cache.SysCache;
+import org.springblade.system.cache.UserCache;
+import org.springblade.system.pojo.entity.Dept;
+import org.springblade.system.pojo.entity.User;
+import org.springblade.transport.constant.HcsFieldConstant;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper;
+import org.springblade.transport.mapper.InvoiceApplicationMapper;
+import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper;
+import org.springblade.transport.mapper.FormalSettlementMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.FormalSettlement;
+import org.springblade.transport.pojo.entity.InvoiceApplication;
+import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
+import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 开票登记同步支撑组件(C111-1 查询判重 / C111-2 新增 / C111-3 更新,设计文档 §5.7,D12)
+ *
+ * 审批通过即推 C111-2(票据日期空);金蝶开票完成(kingdeeStatus=invoiced,由金蝶结果回写置待同步)
+ * → C111-3 回填 fdBillDate/fdStatus。判重 C111-1 conditions={"fdNumber": applicationNo}(§9-U4 联调确认)。
+ * fdContractNumber 缺口:InvoiceApplication 无合同字段,经应收结算关联表回溯合同编号(§9-U10,回溯不到失败提示)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsInvoiceRegSyncSupport {
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final InvoiceApplicationMapper invoiceApplicationMapper;
+ private final InvoiceApplicationSettlementMapper invoiceApplicationSettlementMapper;
+ private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper;
+ private final FormalSettlementMapper formalSettlementMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+
+ /**
+ * 推送开票登记:无合规ID 走判重+C111-2 新增(金蝶已开票完成时紧接着 C111-3),有合规ID 走 C111-3
+ *
+ * @param application 开票申请单(approvalStatus=approved)
+ * @return 推送结果描述
+ */
+ public String pushInvoiceRegistration(InvoiceApplication application) {
+ if (StringUtil.isBlank(application.getHcsFdId())) {
+ String existFdId = findExistFdId(application);
+ if (StringUtil.isNotBlank(existFdId)) {
+ markSynced(application, existFdId, "判重命中,回填已有合规开票登记ID");
+ return "判重命中,回填已有合规开票登记ID";
+ }
+ String fdId = addInvoiceRegistration(application);
+ // 开票登记ID 立即持久化(调用方必须保存)
+ markSynced(application, fdId, "C111-2 开票登记新增成功");
+ String result = "C111-2 开票登记新增成功";
+ // 金蝶已开票完成(新增前回写竞态):紧接着回填票据日期
+ if (KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(application.getKingdeeStatus())) {
+ result += ";" + updateInvoiceRegistration(application);
+ }
+ return result;
+ }
+ String result = updateInvoiceRegistration(application);
+ markSynced(application, application.getHcsFdId(), "C111-3 开票登记更新成功");
+ return result;
+ }
+
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送
+ *
+ * @param id 开票申请单ID
+ * @return 推送结果描述
+ */
+ public String repushInvoiceRegistration(Long id) {
+ InvoiceApplication application = invoiceApplicationMapper.selectById(id);
+ if (application == null) {
+ throw new ServiceException("开票申请单不存在:" + id);
+ }
+ if (!"approved".equals(application.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的开票申请可推送合规(当前状态:" + application.getApprovalStatus() + ")");
+ }
+ application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ application.setHcsRetryCount(0);
+ invoiceApplicationMapper.updateById(application);
+ try {
+ return pushInvoiceRegistration(application);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(application, message);
+ throw new ServiceException("HCS开票登记推送失败:" + message);
+ }
+ }
+
+ /**
+ * C111-2 新增开票登记,返回合规 fdId(字段标注照 §3.4 规范,映射见设计文档 §5.7)
+ */
+ private String addInvoiceRegistration(InvoiceApplication application) {
+ HcsInvoiceRegAddDTO param = new HcsInvoiceRegAddDTO();
+ // 单据编号:开票申请单号(同租户唯一,判重候选键)
+ param.setFdNumber(application.getApplicationNo());
+ // 登记日期:applicationDate(毫秒时间戳)
+ param.setFdRegisterDate(toMillis(application.getApplicationDate()));
+ // 【必填】合同编号:经应收结算链路回溯(§9-U10 关联字段确认后细化)
+ ContractRef contractRef = requireContractRef(application);
+ param.setFdContractNumber(contractRef.contractNo());
+ // 合同 {fdId, fdName}:fdId 传合规合同ID(未推送不传),名称取结算单合同名称(v4 新增)
+ param.setFdContract(contractRef.contract());
+ // 发票类型:invoice-type-mapping 字典映射(§9-U3,未配置不传)
+ param.setFdInvoiceType(hcsProperties.getInvoice().getInvoiceTypeMapping().get(application.getInvoiceType()));
+ if (param.getFdInvoiceType() == null) {
+ log.warn("HCS开票登记发票类型映射缺失:invoiceType=[{}] 不在 invoice.invoice-type-mapping,fdInvoiceType 不传(§9-U3)",
+ application.getInvoiceType());
+ }
+ // 【必填】结算币种:currency-id-mapping(TMS 币种码 → HCS 币种fdId,经 C103-12 缓存取值)
+ param.setFdCurrencyId(requireCurrencyId(application));
+ // 开票方(销方):issuerName
+ param.setFdInvoiceParty(application.getIssuerName());
+ // 款项性质:配置 invoice.fund-nature-default(默认 progress)
+ param.setFdFundNature(hcsProperties.getInvoice().getFundNatureDefault());
+ // 开票金额(含税):invoiceAmount
+ param.setFdInvoiceAmount(application.getInvoiceAmount());
+ // 备注:remark
+ param.setFdRemark(application.getRemark());
+ // 状态:新增固定 0(已开票)
+ param.setFdStatus(0);
+ // 第三方创建人编码:申请人工号(User.code)
+ param.setFdThirdCreatorCode(resolveCreatorCode(application));
+ // 第三方创建部门编码:申请人部门编码
+ param.setFdThirdCreatorDeptCode(resolveDeptCode(application));
+ // 附件ID列表:attachmentsJson 经 C103-2 转传
+ if (hcsProperties.getAttachment().isTransferEnabled() && StringUtil.isNotBlank(application.getAttachmentsJson())) {
+ List attachmentRefs = hcsAttachmentSupport.transferAttachments(
+ HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, application.getId(),
+ application.getAttachmentsJson(), resolveCreatorCode(application));
+ param.setFdAttIds(attachmentRefs.stream().map(HcsAttachmentRefDTO::getFdId).toList());
+ }
+ log.info("HCS开票登记新增开始:applicationNo={}", application.getApplicationNo());
+ HcsResult result = hcsClient.invoiceRegistrationAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS开票登记新增失败响应:{},applicationNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), application.getApplicationNo());
+ throw new HcsException("C111-2 开票登记新增失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getFdId();
+ }
+
+ /**
+ * C111-3 更新开票登记:回填票据日期与状态(金蝶开票完成后)
+ */
+ private String updateInvoiceRegistration(InvoiceApplication application) {
+ HcsInvoiceRegUpdateDTO param = new HcsInvoiceRegUpdateDTO();
+ // 【必填】开票登记ID(C111-2 新增返回)
+ param.setFdId(application.getHcsFdId());
+ // 票据日期:金蝶回写发票的最新开票日期(无发票记录取回写时间,待联调确认口径)
+ param.setFdBillDate(latestInvoiceDate(application));
+ // 状态:0 已开票
+ param.setFdStatus(0);
+ log.info("HCS开票登记更新开始:applicationNo={},fdId={}", application.getApplicationNo(), application.getHcsFdId());
+ HcsResult result = hcsClient.invoiceRegistrationUpdate(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS开票登记更新失败响应:{},applicationNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), application.getApplicationNo());
+ throw new HcsException("C111-3 开票登记更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return "C111-3 开票登记更新成功";
+ }
+
+ /**
+ * C111-1 开票登记列表判重(conditions={"fdNumber": applicationNo},§9-U4 查询能力联调确认)
+ */
+ private String findExistFdId(InvoiceApplication application) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ query.setColumns(List.of(HcsFieldConstant.INVOICE_FD_ID, HcsFieldConstant.INVOICE_FD_NUMBER));
+ // 判重条件:单据编号精确匹配
+ query.setConditions(Map.of(HcsFieldConstant.INVOICE_FD_NUMBER, Objects.toString(application.getApplicationNo(), "")));
+ query.setOffset(0);
+ query.setPageSize(10);
+ HcsResult> result = hcsClient.invoiceRegistrationList(query);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS开票登记判重查询失败响应:{},applicationNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), application.getApplicationNo());
+ throw new HcsException("C111-1 开票登记判重查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsPageVO page = result.getData();
+ if (page == null || page.getContent() == null || page.getContent().isEmpty()) {
+ return null;
+ }
+ return page.getContent().get(0).getFdId();
+ }
+
+ /**
+ * 应收结算链路回溯合同引用:开票申请 → 应收结算关联 → 正式结算单合同(§9-U10);
+ * fdContractNumber 必填,fdContract 随链路回填(fdId 为合规合同ID,未推送不传)
+ */
+ private ContractRef requireContractRef(InvoiceApplication application) {
+ InvoiceApplicationSettlement relation = invoiceApplicationSettlementMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, application.getId())
+ .last("limit 1"));
+ if (relation != null && relation.getFormalSettlementId() != null) {
+ FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId());
+ if (settlement != null && StringUtil.isNotBlank(settlement.getContractNo())) {
+ HcsRefVO contract = new HcsRefVO();
+ contract.setFdName(settlement.getContractName());
+ if (settlement.getContractId() != null) {
+ ContractManage contractManage = contractManageMapper.selectById(settlement.getContractId());
+ if (contractManage != null) {
+ contract.setFdId(contractManage.getHcsFdId());
+ }
+ }
+ return new ContractRef(settlement.getContractNo(), contract);
+ }
+ }
+ throw new HcsException("C111 开票登记推送失败,应收结算链路回溯不到合同编号(applicationNo=" + application.getApplicationNo()
+ + ",§9-U10 待确认关联字段)");
+ }
+
+ /**
+ * 合同引用(fdContractNumber / fdContract)
+ */
+ private record ContractRef(String contractNo, HcsRefVO contract) {
+ }
+
+ /**
+ * 结算币种ID(currency-id-mapping,必填;TMS 无币种字段按 RMB 取映射)
+ */
+ private String requireCurrencyId(InvoiceApplication application) {
+ String currencyFdId = hcsProperties.getInvoice().getCurrencyIdMapping().get("RMB");
+ if (StringUtil.isBlank(currencyFdId)) {
+ throw new HcsException("C111 开票登记推送失败,币种ID 映射未配置(invoice.currency-id-mapping RMB,§9-U3)");
+ }
+ return currencyFdId;
+ }
+
+ /**
+ * 金蝶回写发票的最新开票日期(毫秒),无发票记录取金蝶回写时间
+ */
+ private Long latestInvoiceDate(InvoiceApplication application) {
+ InvoiceApplicationInvoice invoice = invoiceApplicationInvoiceMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, application.getId())
+ .orderByDesc(InvoiceApplicationInvoice::getInvoiceDate)
+ .last("limit 1"));
+ LocalDateTime invoiceDate = invoice != null && invoice.getInvoiceDate() != null
+ ? invoice.getInvoiceDate() : application.getSyncedTime();
+ return toMillis(invoiceDate);
+ }
+
+ /**
+ * 回填整体同步状态置 synced
+ */
+ public void markSynced(InvoiceApplication application, String fdId, String message) {
+ application.setHcsFdId(fdId);
+ application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ application.setHcsRetryCount(0);
+ application.setHcsSyncTime(LocalDateTime.now());
+ application.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ invoiceApplicationMapper.updateById(application);
+ log.info("HCS开票登记推送成功:applicationNo={},fdId={},{}", application.getApplicationNo(), fdId, message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因
+ */
+ public void markFailed(InvoiceApplication application, String message) {
+ application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ application.setHcsRetryCount(application.getHcsRetryCount() == null ? 1 : application.getHcsRetryCount() + 1);
+ application.setHcsSyncTime(LocalDateTime.now());
+ application.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ invoiceApplicationMapper.updateById(application);
+ log.error("HCS开票登记推送失败:applicationNo={},retryCount={},{}", application.getApplicationNo(), application.getHcsRetryCount(), message);
+ }
+
+ /**
+ * 申请人工号(User.code,fdThirdCreatorCode)
+ */
+ private String resolveCreatorCode(InvoiceApplication application) {
+ User user = application.getCreateUser() == null ? null : UserCache.getUser(application.getCreateUser());
+ return user == null ? null : user.getCode();
+ }
+
+ /**
+ * 申请人部门编码(fdThirdCreatorDeptCode)
+ */
+ private String resolveDeptCode(InvoiceApplication application) {
+ User user = application.getCreateUser() == null ? null : UserCache.getUser(application.getCreateUser());
+ if (user == null) {
+ return null;
+ }
+ // 用户冗余部门编码优先(可能逗号分隔,取第一个)
+ if (StringUtil.isNotBlank(user.getDeptCodes())) {
+ return user.getDeptCodes().split(",")[0].trim();
+ }
+ Long deptId = Func.toLong(user.getDeptId() == null ? null : user.getDeptId().split(",")[0].trim());
+ Dept dept = deptId == null ? null : SysCache.getDept(deptId);
+ return dept == null ? null : dept.getDeptCode();
+ }
+
+ /**
+ * LocalDateTime/LocalDate → 毫秒时间戳
+ */
+ private Long toMillis(LocalDateTime localDateTime) {
+ return localDateTime == null ? null : localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+
+ /**
+ * LocalDate → 毫秒时间戳
+ */
+ private Long toMillis(java.time.LocalDate localDate) {
+ return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java
new file mode 100644
index 00000000..0c123fd8
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java
@@ -0,0 +1,207 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.json.JSONUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.redis.cache.BladeRedis;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsCategoryVO;
+import org.springblade.hcs.dto.HcsCurrencyVO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsTemplateVO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.hcs.util.HcsPageUtil;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * HCS 主数据缓存支撑组件(C103-6 模板 / C103-7 分类 / C103-12 币种,设计文档 §5.9)
+ *
+ * 启动/首次使用时拉取并缓存(Redis,照 BFI token 缓存惯例,TTL 可配 master-data.ttl-minutes):
+ * 模板列表供 template-mapping 校验,币种表供 currency-id-mapping 取值。
+ * 提供管理端"刷新缓存"入口(HcsSyncController)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsMasterDataSupport {
+
+ /**
+ * 主数据分页拉取页大小
+ */
+ private static final int PAGE_SIZE = 200;
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final BladeRedis bladeRedis;
+
+ /**
+ * 获取合同模板列表(缓存优先,未命中全量拉取)
+ */
+ public List getTemplates() {
+ List cached = readCache(HcsSyncConstant.MASTER_CACHE_TEMPLATES, HcsTemplateVO.class);
+ if (cached != null) {
+ return cached;
+ }
+ return loadTemplates();
+ }
+
+ /**
+ * 获取合同分类列表(缓存优先,未命中全量拉取)
+ */
+ public List getCategories() {
+ List cached = readCache(HcsSyncConstant.MASTER_CACHE_CATEGORIES, HcsCategoryVO.class);
+ if (cached != null) {
+ return cached;
+ }
+ return loadCategories();
+ }
+
+ /**
+ * 获取币种列表(缓存优先,未命中全量拉取)
+ */
+ public List getCurrencies() {
+ List cached = readCache(HcsSyncConstant.MASTER_CACHE_CURRENCIES, HcsCurrencyVO.class);
+ if (cached != null) {
+ return cached;
+ }
+ return loadCurrencies();
+ }
+
+ /**
+ * 强制刷新三类主数据缓存(管理端入口),并对 template-mapping 做存在性校验
+ *
+ * @return 刷新结果汇总
+ */
+ public String refreshAll() {
+ int templateCount = loadTemplates().size();
+ int categoryCount = loadCategories().size();
+ int currencyCount = loadCurrencies().size();
+ validateTemplateMapping();
+ String summary = String.format("HCS主数据缓存已刷新:模板 %d 条、分类 %d 条、币种 %d 条", templateCount, categoryCount, currencyCount);
+ log.info(summary);
+ return summary;
+ }
+
+ /**
+ * 全量拉取合同模板并缓存
+ */
+ private List loadTemplates() {
+ HcsPageQueryDTO query = baseQuery();
+ List rows = HcsPageUtil.queryAllPages(offset -> {
+ query.setOffset(offset);
+ return hcsClient.templateList(query).getData();
+ });
+ writeCache(HcsSyncConstant.MASTER_CACHE_TEMPLATES, rows);
+ log.info("HCS合同模板列表已缓存,共 {} 条", rows.size());
+ return rows;
+ }
+
+ /**
+ * 全量拉取合同分类并缓存
+ */
+ private List loadCategories() {
+ HcsPageQueryDTO query = baseQuery();
+ List rows = HcsPageUtil.queryAllPages(offset -> {
+ query.setOffset(offset);
+ return hcsClient.categoryList(query).getData();
+ });
+ writeCache(HcsSyncConstant.MASTER_CACHE_CATEGORIES, rows);
+ log.info("HCS合同分类列表已缓存,共 {} 条", rows.size());
+ return rows;
+ }
+
+ /**
+ * 全量拉取币种并缓存
+ */
+ private List loadCurrencies() {
+ HcsPageQueryDTO query = baseQuery();
+ List rows = HcsPageUtil.queryAllPages(offset -> {
+ query.setOffset(offset);
+ return hcsClient.currencyList(query).getData();
+ });
+ writeCache(HcsSyncConstant.MASTER_CACHE_CURRENCIES, rows);
+ log.info("HCS币种列表已缓存,共 {} 条", rows.size());
+ return rows;
+ }
+
+ /**
+ * 校验 contract.template-mapping 中的模板ID是否存在于 HCS 模板列表(§9-U7,仅告警不阻断)
+ */
+ private void validateTemplateMapping() {
+ Map mapping = hcsProperties.getContract().getTemplateMapping();
+ if (mapping.isEmpty()) {
+ log.warn("HCS合同模板映射(thirdParty.hcs.contract.template-mapping)未配置,合同推送将失败(§9-U7 待合规方提供模板ID清单)");
+ return;
+ }
+ List templateIds = getTemplates().stream().map(HcsTemplateVO::getFdId).toList();
+ mapping.forEach((category, templateId) -> {
+ if (StringUtil.isNotBlank(templateId) && !templateIds.contains(templateId)) {
+ log.warn("HCS合同模板映射校验告警:合同类别 [{}] 映射的模板ID [{}] 不在合规模板列表中", category, templateId);
+ }
+ });
+ }
+
+ /**
+ * 分页查询基础参数(offset/pageSize 必填,columns 各主数据接口选填)
+ */
+ private HcsPageQueryDTO baseQuery() {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ // 【必填】每页条数
+ query.setPageSize(PAGE_SIZE);
+ return query;
+ }
+
+ /**
+ * 读缓存(JSON 字符串反序列化)
+ */
+ private List readCache(String cacheKey, Class itemType) {
+ String json = bladeRedis.get(cacheKey);
+ if (StringUtil.isBlank(json)) {
+ return null;
+ }
+ return JSONUtil.toList(json, itemType);
+ }
+
+ /**
+ * 写缓存(JSON 字符串 + 可配 TTL)
+ */
+ private void writeCache(String cacheKey, List> rows) {
+ bladeRedis.getValueOps().setIfAbsent(cacheKey, JSONUtil.toJsonStr(rows),
+ Duration.ofMinutes(hcsProperties.getMasterData().getTtlMinutes()));
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java
new file mode 100644
index 00000000..46059777
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java
@@ -0,0 +1,327 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsLegalResultDTO;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.constant.TransportBusiConstant;
+import org.springblade.transport.event.KingdeeBillApprovedEvent;
+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.PaymentApplication;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * HCS 付款链推送编排支撑组件(结算付款特批 C106-3/4 + 履约计划重推 C103-9 + 履约记录 C108-1/2 + 特批结果推送接收,设计文档 §5.0/§5.3/§5.5/§5.6)
+ *
+ * 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 特批通过后再推履约计划(付款审批款项金额累加行)→ 履约记录(审批通过新建 / 实际付款推进更新)。
+ * 特批审核期间(need/pushing/processing)仅发起/等待 C106,不推送履约计划与履约记录;驳回不产生履约数据。
+ * 特批流程ID 与履约记录ID 在各自 Support 内即时持久化;整体同步状态(hcs_sync_status)由本组件统一回写。
+ * 特批发起后 TMS 挂起 currentNode="特批审核",合规 paymentLegal/result 回调联动付款审批状态
+ * (通过恢复"审批通过"并补推履约数据 / 驳回联动审批驳回走重新提交闭环),人工确认等效兜底。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsPaymentSyncSupport {
+
+ /**
+ * 审批通过状态集合
+ */
+ private static final List APPROVED_STATUSES = List.of("approved");
+
+ private final PaymentApplicationMapper paymentApplicationMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsProperties hcsProperties;
+ private final HcsSpecPaySyncSupport hcsSpecPaySyncSupport;
+ private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
+ private final HcsPerfRecordSyncSupport hcsPerfRecordSyncSupport;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 特批通过后履约计划重推(C103-9 款项金额累加行)→ 履约记录(新建/更新)→ 回填整体同步状态
+ *
+ * @param payment 付款申请单(approvalStatus=approved)
+ * @return 推送结果描述
+ */
+ public String pushPayment(PaymentApplication payment) {
+ StringBuilder result = new StringBuilder();
+ // ① 结算付款特批:提交时高风险/黑名单(须特批附件)或额度超额(hcs_spec_status=need)且未发起时发起 C106-3;
+ // 发起后挂起等待特批结果,履约计划与履约记录由特批通过后的 pushAfterSpecApproved 补推(驳回不产生履约数据)
+ if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) {
+ String specResult = hcsSpecPaySyncSupport.pushSpecPay(payment);
+ // C106-4 更新路径不改状态,统一归一为特批审核中,避免下轮扫描重复发起
+ if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) {
+ payment.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_PROCESSING);
+ payment.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE);
+ }
+ markSynced(payment, specResult);
+ return specResult;
+ }
+ // 特批审核中:仅等待合规回调/人工确认,不推送履约计划与履约记录
+ if (HcsSyncConstant.SPEC_STATUS_PUSHING.equals(payment.getHcsSpecStatus())
+ || HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(payment.getHcsSpecStatus())) {
+ String message = "付款特批审核中,待特批通过后推送履约数据";
+ markSynced(payment, message);
+ return message;
+ }
+ // ② 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行,
+ // 凭已存合规阶段ID 重推;独立于履约记录开关,推送失败抛出由付款链置 failed 重试兜底
+ String stageResult = this.pushExeStageUpdate(payment);
+ if (StringUtil.isNotBlank(stageResult)) {
+ if (result.length() > 0) {
+ result.append(";");
+ }
+ result.append(stageResult);
+ }
+ // ③ 履约记录:审批通过新建 C108-1 / 实际付款推进更新 C108-2(一期暂不对接默认关)
+ if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
+ markSynced(payment, result.toString());
+ return result.toString();
+ }
+ if (result.length() > 0) {
+ result.append(";");
+ }
+ result.append(hcsPerfRecordSyncSupport.pushPerfRecord(payment));
+ markSynced(payment, result.toString());
+ return result.toString();
+ }
+
+ /**
+ * 履约计划重推(C103-9):聚合合同下 row_op 非 none 的计划行(含付款审批累加款项金额的 update 行)凭已存 fdId 重推;
+ * 合同尚未推送合规时计划行随合同链路首推(首推即携带累加后金额),此处跳过
+ *
+ * @param payment 付款申请单
+ * @return 推送结果描述,无待同步行返回空串
+ */
+ private String pushExeStageUpdate(PaymentApplication payment) {
+ if (payment.getContractId() == null) {
+ return "";
+ }
+ ContractManage contractManage = contractManageMapper.selectById(payment.getContractId());
+ if (contractManage == null || StringUtil.isBlank(contractManage.getHcsFdId())) {
+ return "";
+ }
+ String stageResult = hcsExeStageSyncSupport.syncExeStages(contractManage);
+ return "履约规则无待同步行".equals(stageResult) ? "" : stageResult;
+ }
+
+ /**
+ * 特批通过后补推履约数据(C103-9 计划重推 + C108 履约记录):补推失败置 failed 留待人工重推兜底,
+ * 不阻断特批结果落库与金蝶补推
+ *
+ * @param payment 付款申请单(hcs_spec_status=approved)
+ */
+ private void pushAfterSpecApproved(PaymentApplication payment) {
+ try {
+ this.pushPayment(payment);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "履约数据推送异常" : exception.getMessage();
+ markFailed(payment, message);
+ log.error("付款特批通过后履约数据推送失败:paymentNo={},{}", payment.getPaymentNo(), message, exception);
+ }
+ }
+
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
+ *
+ * @param id 付款申请单ID
+ * @return 推送结果描述
+ */
+ public String repushPayment(Long id) {
+ PaymentApplication payment = paymentApplicationMapper.selectById(id);
+ if (payment == null) {
+ throw new ServiceException("付款申请单不存在:" + id);
+ }
+ if (!APPROVED_STATUSES.contains(payment.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的付款申请可推送合规(当前状态:" + payment.getApprovalStatus() + ")");
+ }
+ payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ payment.setHcsRetryCount(0);
+ paymentApplicationMapper.updateById(payment);
+ try {
+ return pushPayment(payment);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(payment, message);
+ throw new ServiceException("HCS付款申请推送失败:" + message);
+ }
+ }
+
+ /**
+ * 特批结果人工确认(页面确认与合规 paymentLegal/result 回调等效,§9-U1 过渡兜底)
+ *
+ * @param id 付款申请单ID
+ * @param result confirmed approved / rejected
+ * @return 确认结果描述
+ */
+ public String confirmSpecResult(Long id, String result) {
+ PaymentApplication payment = paymentApplicationMapper.selectById(id);
+ if (payment == null) {
+ throw new ServiceException("付款申请单不存在:" + id);
+ }
+ if (!Arrays.asList(HcsSyncConstant.SPEC_STATUS_APPROVED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(result)) {
+ throw new ServiceException("特批结果仅支持 approved/rejected");
+ }
+ if (!HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(payment.getHcsSpecStatus())) {
+ throw new ServiceException("仅特批中的付款申请允许确认结果(当前状态:" + payment.getHcsSpecStatus() + ")");
+ }
+ // 特批结果联动付款审批状态(通过恢复放行 / 驳回联动审批驳回),随 updateById 一并落库
+ syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result));
+ payment.setHcsSpecStatus(result);
+ paymentApplicationMapper.updateById(payment);
+ // 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推
+ if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)) {
+ this.pushAfterSpecApproved(payment);
+ }
+ publishKingdeeAutoSyncOnSpecApproved(payment, result);
+ log.info("HCS特批结果人工确认:paymentNo={},specFdId={},结果={}", payment.getPaymentNo(), payment.getHcsSpecFdId(), result);
+ return "特批结果已确认:" + result;
+ }
+
+ /**
+ * 付款特批结果接收处理(合规 paymentLegal/result 回调通知特批审批结果):
+ * sourceSystem 须与 third-party.hcs.src-system 配置一致(不符抛出"来源系统标识错误"异常),
+ * requestId=TMS 付款申请ID、status=APPROVED/REJECTED;
+ * 特批结果联动付款审批状态:approved 恢复"审批通过"放行(后续金蝶推送放行),
+ * rejected 联动付款审批驳回(returned),走"重新编辑→提交→审批→重新特批"闭环
+ *
+ * @param param 合规推送报文
+ * @return 处理结果描述
+ */
+ public String handleLegalResult(HcsLegalResultDTO param) {
+ // 来源系统校验:仅接受 third-party.hcs.src-system 配置的来源,防伪造回传
+ if (!StrUtil.equalsIgnoreCase(param == null ? null : param.getSourceSystem(), hcsProperties.getSrcSystem())) {
+ log.warn("付款特批结果推送来源系统标识错误:sourceSystem={}", param == null ? null : param.getSourceSystem());
+ throw new ServiceException("来源系统标识错误");
+ }
+ String requestId = param == null ? null : param.getRequestId();
+ PaymentApplication payment = locatePayment(requestId);
+ if (payment == null) {
+ log.warn("付款特批结果推送未定位到付款申请:requestId={},reason={}", requestId, param == null ? null : param.getReason());
+ return "未定位到付款申请(requestId=" + requestId + ")";
+ }
+ String normalizedStatus = param.getStatus() == null ? "" : param.getStatus().trim();
+ String status;
+ if (HcsSyncConstant.LEGAL_RESULT_APPROVED.equalsIgnoreCase(normalizedStatus)) {
+ status = HcsSyncConstant.SPEC_STATUS_APPROVED;
+ } else if (HcsSyncConstant.LEGAL_RESULT_REJECTED.equalsIgnoreCase(normalizedStatus)) {
+ status = HcsSyncConstant.SPEC_STATUS_REJECTED;
+ } else {
+ log.warn("付款特批结果状态未识别:paymentNo={},status={},reason={}", payment.getPaymentNo(), param.getStatus(), param.getReason());
+ return "状态字段未识别(paymentNo=" + payment.getPaymentNo() + ")";
+ }
+ syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status));
+ payment.setHcsSpecStatus(status);
+ paymentApplicationMapper.updateById(payment);
+ // 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推
+ if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status)) {
+ this.pushAfterSpecApproved(payment);
+ }
+ publishKingdeeAutoSyncOnSpecApproved(payment, status);
+ log.info("付款特批结果推送处理完成:paymentNo={},结果={},reason={}", payment.getPaymentNo(), status, param.getReason());
+ return "特批结果已处理:" + status;
+ }
+
+ /**
+ * 特批通过后自动补推金蝶:审批通过时因特批挂起已跳过同步(见 KingdeeApprovalSyncListener),
+ * 此处重新发布审批通过事件复用同一条自动推送链(开关、失败落库与重推兜底口径不变)
+ */
+ private void publishKingdeeAutoSyncOnSpecApproved(PaymentApplication payment, String specStatus) {
+ if (!HcsSyncConstant.SPEC_STATUS_APPROVED.equals(specStatus)) {
+ return;
+ }
+ eventPublisher.publishEvent(new KingdeeBillApprovedEvent(KingdeeBillApprovedEvent.BIZ_PAYMENT_APPLICATION, payment.getId()));
+ }
+
+ /**
+ * 特批结果联动付款审批状态(handleLegalResult 与 confirmSpecResult 共用,仅改字段,随 updateById 落库):
+ * approved 恢复"审批通过"展示并放行(审批状态保持 approved,金蝶推送门禁解除);
+ * rejected 联动付款审批驳回(returned + "已驳回"),走重新编辑→提交→审批→重新特批闭环
+ * (提交时重新校验收款方风险与特批附件)
+ */
+ private void syncPaymentOnSpecResult(PaymentApplication payment, boolean approved) {
+ if (approved) {
+ payment.setCurrentNode("审批通过");
+ } else {
+ payment.setApprovalStatus(TransportBusiConstant.RETURNED);
+ payment.setCurrentNode("已驳回");
+ payment.setCurrentProcessor("待处理");
+ }
+ }
+
+ /**
+ * 按 TMS 付款申请ID 定位付款申请(合规推送 requestId)
+ */
+ private PaymentApplication locatePayment(String requestId) {
+ if (StringUtil.isBlank(requestId)) {
+ return null;
+ }
+ try {
+ return paymentApplicationMapper.selectById(Long.valueOf(requestId.trim()));
+ } catch (NumberFormatException exception) {
+ return null;
+ }
+ }
+
+ /**
+ * 回填整体同步状态置 synced
+ */
+ public void markSynced(PaymentApplication payment, String message) {
+ payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ payment.setHcsRetryCount(0);
+ payment.setHcsSyncTime(LocalDateTime.now());
+ payment.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ paymentApplicationMapper.updateById(payment);
+ log.info("HCS付款申请推送成功:paymentNo={},{}", payment.getPaymentNo(), message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因
+ */
+ public void markFailed(PaymentApplication payment, String message) {
+ payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ payment.setHcsRetryCount(payment.getHcsRetryCount() == null ? 1 : payment.getHcsRetryCount() + 1);
+ payment.setHcsSyncTime(LocalDateTime.now());
+ payment.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ paymentApplicationMapper.updateById(payment);
+ log.error("HCS付款申请推送失败:paymentNo={},retryCount={},{}", payment.getPaymentNo(), payment.getHcsRetryCount(), message);
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java
new file mode 100644
index 00000000..7e7aa5a0
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java
@@ -0,0 +1,225 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsMechanismsDTO;
+import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO;
+import org.springblade.hcs.dto.HcsPerfRecordPropsDTO;
+import org.springblade.hcs.dto.HcsPerfRecordSaveDTO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsUserRefVO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+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.time.LocalDate;
+import java.time.ZoneId;
+import java.util.List;
+
+/**
+ * HCS 履约记录同步支撑组件(C108-1 新建 / C108-2 更新,设计文档 §5.6,D11)
+ *
+ * 付款申请为主线:审批通过 → C108-1 新增(返回 fdId 回填 hcs_perf_fd_id);
+ * 实际付款推进(paidAmount 变化置待同步)→ C108-2 更新(formData.fdId=hcs_perf_fd_id 必填,响应无 data)。
+ * 关联履约阶段:按合同+付款类型匹配已同步的履约规则行(付款/收款执行与计划阶段对应),
+ * 无关联则不传 fd_id(§5.6)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsPerfRecordSyncSupport {
+
+ /**
+ * 履约记录附件字段标识
+ */
+ private static final String PERF_ATTACH_ENTITY_KEY = "fd_plan_att";
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ContractManageMapper contractManageMapper;
+ private final IContractExeStageService contractExeStageService;
+ private final PaymentApplicationMapper paymentApplicationMapper;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+
+ /**
+ * 推送履约记录:无记录ID 走 C108-1 新增,有记录ID 走 C108-2 更新;记录ID 立即持久化
+ *
+ * @param payment 付款申请单(approvalStatus=approved)
+ * @return 推送结果描述
+ */
+ public String pushPerfRecord(PaymentApplication payment) {
+ String operatorLoginName = resolveLoginName(payment);
+ if (StringUtil.isBlank(payment.getHcsPerfFdId())) {
+ HcsPerfRecordSaveDTO param = buildAddParam(payment, operatorLoginName);
+ log.info("HCS履约记录新建开始:paymentNo={}", payment.getPaymentNo());
+ HcsResult result = hcsClient.perfRecordAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS履约记录新建失败响应:{},paymentNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo());
+ throw new HcsException("C108-1 履约记录新建失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ // 履约记录ID 立即持久化(调用方必须保存)
+ payment.setHcsPerfFdId(result.getData().getFdId());
+ paymentApplicationMapper.updateById(payment);
+ log.info("HCS履约记录新建成功:paymentNo={},perfFdId={}", payment.getPaymentNo(), payment.getHcsPerfFdId());
+ return "C108-1 履约记录新建成功";
+ }
+ // C108-2 更新:formData.fdId 必填,响应无 data,success=true 即成功
+ HcsPerfRecordSaveDTO param = buildUpdateParam(payment, operatorLoginName);
+ log.info("HCS履约记录更新开始:paymentNo={},perfFdId={}", payment.getPaymentNo(), payment.getHcsPerfFdId());
+ HcsResult result = hcsClient.perfRecordUpdate(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS履约记录更新失败响应:{},paymentNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo());
+ throw new HcsException("C108-2 履约记录更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return "C108-2 履约记录更新成功";
+ }
+
+ /**
+ * C108-1 新建入参(字段标注照 §3.4 规范,映射见设计文档 §5.6)
+ */
+ private HcsPerfRecordSaveDTO buildAddParam(PaymentApplication payment, String operatorLoginName) {
+ HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO();
+ // 【必填】当前操作人 {fdLoginName}(付款申请创建人,§9-U2)
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ param.setFormData(buildFormData(payment));
+ // 【必填】机制数据(附件):C108-1 必填,无附件传空列表(附件转传开关关闭或无附件时)
+ HcsMechanismsDTO mechanisms = buildMechanisms(payment, operatorLoginName);
+ if (mechanisms == null) {
+ mechanisms = new HcsMechanismsDTO();
+ mechanisms.setAttachment(List.of());
+ }
+ param.setMechanisms(mechanisms);
+ return param;
+ }
+
+ /**
+ * C108-2 更新入参:formData.fdId 必填,传实际金额与合同ID
+ */
+ private HcsPerfRecordSaveDTO buildUpdateParam(PaymentApplication payment, String operatorLoginName) {
+ HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO();
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ HcsPerfRecordFormDataDTO formData = buildFormData(payment);
+ // 【必填】履约记录ID(C108-1 新建返回)
+ formData.setFdId(payment.getHcsPerfFdId());
+ param.setFormData(formData);
+ return param;
+ }
+
+ /**
+ * 履约记录 formData(新建/更新共用)
+ */
+ private HcsPerfRecordFormDataDTO buildFormData(PaymentApplication payment) {
+ ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId());
+ HcsContractExeStage stageRow = findPaymentStageRow(payment);
+ HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO();
+ HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
+ // 申请金额:appliedAmount(接口按字符串传值)
+ props.setFdApplyAmount(payment.getAppliedAmount() == null ? null : payment.getAppliedAmount().toPlainString());
+ // 实际金额:paidAmount(实际付款推进后更新)
+ props.setFdFactAmount(payment.getPaidAmount() == null ? null : payment.getPaidAmount().toPlainString());
+ // 【必填】申请编号:付款申请单号
+ props.setFdApplyNo(payment.getPaymentNo());
+ // 【必填】计划日期:applyDate(毫秒时间戳)
+ props.setFdPlanDate(toMillis(payment.getApplyDate()));
+ // 计划类型:paymentType(原文拼写 fd_paln_type 必须沿用)
+ props.setFdPalnType(payment.getPaymentType());
+ // 性质:款项性质取关联履约规则行的款项性质(无关联不传)
+ props.setFdProperty(stageRow == null ? null : stageRow.getFundNature());
+ // 来源履约计划项ID:关联履约阶段 hcs_stage_fd_id(无关联则不传,§5.6)
+ props.setFdId(stageRow == null ? null : stageRow.getHcsStageFdId());
+ // 【必填】合同ID:HCS 合同ID(合同未推送时为空,联调确认兜底口径)
+ props.setFdContractId(contractManage == null ? null : contractManage.getHcsFdId());
+ // 业务ID:TMS 付款申请单主键(方便溯源,v4 新增)
+ props.setFdBusinessId(payment.getId() == null ? null : String.valueOf(payment.getId()));
+ // 创建时间:createTime(毫秒时间戳,v4 新增)
+ props.setFdCreateTime(payment.getCreateTime() == null ? null : payment.getCreateTime().toInstant().toEpochMilli());
+ // 表单模板ID:配置 perf-record.xform-id(环境值,§9-U9,未配置不传)
+ String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId();
+ props.setFdXformId(xformId);
+ // 【必填】表单模板ID(formData 顶层 fd_xform_id,v4 必填)
+ formData.setFdXformId(xformId);
+ formData.setDynamicProps(props);
+ return formData;
+ }
+
+ /**
+ * 履约记录附件挂载(无附件或开关关闭返回 null)
+ */
+ private HcsMechanismsDTO buildMechanisms(PaymentApplication payment, String operatorLoginName) {
+ if (!hcsProperties.getAttachment().isTransferEnabled() || StringUtil.isBlank(payment.getAttachmentsJson())) {
+ return null;
+ }
+ List attachmentRefs = hcsAttachmentSupport.transferAttachments(
+ HcsSyncConstant.BIZ_TYPE_PAYMENT, payment.getId(), payment.getAttachmentsJson(), operatorLoginName);
+ if (attachmentRefs.isEmpty()) {
+ return null;
+ }
+ HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
+ mechanisms.setAttachment(attachmentRefs.stream().map(ref -> {
+ // 附件ID(C103-2 转传返回)
+ ref.setFdEntityKey(PERF_ATTACH_ENTITY_KEY);
+ return ref;
+ }).toList());
+ return mechanisms;
+ }
+
+ /**
+ * 匹配合同的付款履约计划行(优先按付款类型映射的款项性质精确匹配,无则取首条已同步付款行兜底)
+ */
+ private HcsContractExeStage findPaymentStageRow(PaymentApplication payment) {
+ return contractExeStageService.findPaymentStageRow(payment.getContractId(),
+ HcsSyncConstant.fundNatureOfPayment(payment.getPaymentType()));
+ }
+
+ /**
+ * 操作人登录名(thirdParty.hcs.operator-login-name,默认 srvadmin,§9-U2)
+ */
+ private String resolveLoginName(PaymentApplication payment) {
+ return hcsProperties.getOperatorLoginName();
+ }
+
+ /**
+ * LocalDate → 毫秒时间戳
+ */
+ private Long toMillis(LocalDate localDate) {
+ return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
new file mode 100644
index 00000000..e24d8e16
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
@@ -0,0 +1,467 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsBpmOperationDTO;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsProjectAddDTO;
+import org.springblade.hcs.dto.HcsProjectFormDataDTO;
+import org.springblade.hcs.dto.HcsProjectLegalResultDTO;
+import org.springblade.hcs.dto.HcsProjectPropsDTO;
+import org.springblade.hcs.dto.HcsProjectUpdateDTO;
+import org.springblade.hcs.dto.HcsProjectVO;
+import org.springblade.hcs.dto.HcsRefVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsSaveOptionDTO;
+import org.springblade.hcs.dto.HcsUserRefVO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsFieldConstant;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.HcsContractCheckLogMapper;
+import org.springblade.transport.mapper.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.HcsContractCheckLog;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 项目立项(C102)同步支撑组件(设计文档 §5.1)
+ *
+ * 触发与链路:审批通过(approved)→ C102-1 新建;变更审批通过(change_approved,hcs_fd_id 已存在)→ C102-2 更新。
+ * 推送前判重:C102-3 conditions={"fd_num": projectCode},命中则直接回填 fdId 置 synced(跳过新建,§9-U4 查询能力联调确认)。
+ * 失败不在此回写,由调用方(HcsPushTask/手动端点)统一 {@link #markFailed} 落 failed 状态进补偿重试。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsProjectSyncSupport {
+
+ /**
+ * TMS 万元 → HCS 元换算倍率
+ */
+ private static final BigDecimal WAN_TO_YUAN = BigDecimal.valueOf(10000);
+
+
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ProjectApplyMapper projectApplyMapper;
+ private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
+
+ /**
+ * 推送项目立项到 HCS:判重 → 新建(C102-1)/ 更新(C102-2)→ 回填合规 fdId 置 synced
+ *
+ * @param projectApply 项目立项单(approvalStatus 须为 approved/change_approved)
+ * @return 推送结果描述(供任务汇总与 hcs_sync_msg 落库)
+ */
+ public String pushProject(ProjectApply projectApply) {
+ // 首推先判重(C102-3),命中直接回填跳过新建;已有合规ID(变更单)走更新
+ if (StringUtil.isBlank(projectApply.getHcsFdId())) {
+ String existFdId = findExistFdId(projectApply);
+ if (StringUtil.isNotBlank(existFdId)) {
+ markSynced(projectApply, existFdId, "判重命中,回填已有合规项目ID");
+ return "判重命中,回填已有合规项目ID";
+ }
+ String fdId = addProject(projectApply);
+ String addMessage = "项目立项新建成功:" + projectApply.getProjectCode();
+ markSynced(projectApply, fdId, addMessage);
+ return addMessage;
+ }
+ updateProject(projectApply);
+ String updateMessage = "项目立项更新成功:" + projectApply.getProjectCode();
+ markSynced(projectApply, projectApply.getHcsFdId(), updateMessage);
+ return updateMessage;
+ }
+
+ /**
+ * C102-1 项目立项新建,返回合规项目 fdId
+ */
+ private String addProject(ProjectApply projectApply) {
+ String operatorLoginName = requireLoginName(projectApply, "C102-1");
+ HcsProjectAddDTO param = new HcsProjectAddDTO();
+
+ HcsBpmOperationDTO bpmOperation = new HcsBpmOperationDTO();
+ // 【必填】操作类型:固定 drafter_submit(以起草人身份直接提交流程)
+ bpmOperation.setOperationType("drafter_submit");
+ // 【必填】立项表单模板编码:配置 thirdParty.hcs.project.template-code(环境值,§9-U9)
+ bpmOperation.setTemplateCode(requireTemplateCode());
+ param.setBpmOperation(bpmOperation);
+ // 是否校验权限:接口调用固定 false
+ param.setCheckAuth(false);
+ // 【必填】当前操作人:项目经办人登录名(§9-U2 登录名同源性联调确认)
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ param.setFormData(buildFormData(projectApply, null));
+ HcsSaveOptionDTO saveOption = new HcsSaveOptionDTO();
+ // 【必填】是否保存草稿:saveDraft=false 直接提交
+ saveOption.setSaveDraft(false);
+ param.setSaveOption(saveOption);
+
+ log.info("HCS项目立项推送开始:{}(C102-1 新建)", projectApply.getProjectCode());
+ HcsResult result = hcsClient.projectAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS项目立项新建失败响应:{},projectCode={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), projectApply.getProjectCode());
+ throw new HcsException("C102-1 项目立项新建失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getFdId();
+ }
+
+ /**
+ * C102-2 项目立项更新(入参同新建,去掉 bpmOperation/saveOption,formData.fdId 必填)
+ */
+ private void updateProject(ProjectApply projectApply) {
+ String operatorLoginName = requireLoginName(projectApply, "C102-2");
+ HcsProjectUpdateDTO param = new HcsProjectUpdateDTO();
+ // 是否校验权限:接口调用固定 false
+ param.setCheckAuth(false);
+ // 【必填】当前操作人:项目经办人登录名
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ // 更新必须携带合规项目 fdId
+ param.setFormData(buildFormData(projectApply, projectApply.getHcsFdId()));
+
+ log.info("HCS项目立项推送开始:{}(C102-2 更新)", projectApply.getProjectCode());
+ HcsResult result = hcsClient.projectUpdate(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS项目立项更新失败响应:{},projectCode={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), projectApply.getProjectCode());
+ throw new HcsException("C102-2 项目立项更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ }
+
+ /**
+ * 组装项目立项 formData(新建/更新共用,字段标注照 §3.4 规范,映射表见设计文档 §5.1)
+ */
+ private HcsProjectFormDataDTO buildFormData(ProjectApply projectApply, String fdId) {
+ HcsProjectFormDataDTO formData = new HcsProjectFormDataDTO();
+ // 合规项目 fdId(C102-2 更新必填,C102-1 不传)
+ formData.setFdId(fdId);
+ HcsProjectPropsDTO props = new HcsProjectPropsDTO();
+ // 【必填】项目名称:ProjectApply.projectName
+ props.setFdProName(projectApply.getProjectName());
+
+ // erp请求id
+ Long id = projectApply.getId();
+ props.setFdErpRequestId(String.valueOf(id));
+
+ // 项目简称:ProjectApply.projectShortName
+ props.setFdProSimpleName(projectApply.getProjectShortName());
+ // 【必填】项目编号:ProjectApply.projectCode(判重候选键)
+ props.setFdNum(projectApply.getProjectCode());
+ // 创建单位:固定取配置 thirdParty.hcs.creator-unit-id/name(HCS 组织主数据,§9-U2;任一未配置回退开题部门名称仅传 fdName,其他合同接口同用此配置)
+ HcsRefVO creatorUnit = hcsProperties.resolveCreatorUnit(projectApply.getBusinessDeptName());
+ props.setFdCreatorUnit(creatorUnit);
+ // 项目负责人:负责人登录名 {fdLoginName}(§9-U2)
+ props.setFdHead(resolveUserRef(projectApply.getPrincipalUserId()));
+ // todo 承接部门:与创建单位一致,取配置 thirdParty.hcs.creator-unit-id/name(测试线联调固定广西物产城矿发展有限公司)
+ props.setFdTakerDept(creatorUnit);
+ // 承接人:项目经办人登录名 {fdLoginName}
+ props.setFdTaker(resolveUserRef(projectApply.getHandlerUserId()));
+ // 付款额度:TMS fundLimit(万元)× 10000 转 HCS 元
+ props.setFdPayLimit(toYuan(projectApply.getFundLimit()));
+ // 开票额度:TMS receivableLimit(万元)× 10000 转 HCS 元
+ props.setFdOutInvoiceLimit(toYuan(projectApply.getReceivableLimit()));
+ // 项目建设内容:ProjectApply.situationRemark
+ props.setFdProContent(projectApply.getSituationRemark());
+ // 【必填】是否可用:固定 true
+ props.setFdIsAvailable(true);
+ // 可用单位列表:与创建单位一致(同走 creator-unit 配置)(废弃)
+ // props.setFdAvailUnits(List.of(creatorUnit));
+
+ // 结算类型:TMS settle_method 字典文本映射 HCS 编码(1=先款后票,2=先票后款,3=先款后票+先票后款(同时存在))
+ props.setFdSettlementType(mapSettlementType(projectApply.getSettlementMode()));
+ // 计划保证金:TMS fundDemand 与 HCS fd_pro_plan_deposit 单位同为万元,直传不换算(接口说明 §5.1)
+ props.setFdProPlanDeposit(projectApply.getFundDemand());
+ // 是否自营项目:一期固定 "2"。是|1, 否|2
+ props.setFdIsSelfPro("2");
+ // TODO 待确认:项目收入目标 fd_pro_income_goal,TMS 无直接对应字段,一期不传(§5.1)
+ // 金蝶同步状态:固定 "1" 推送成功|1
+ // props.setFdKingdeeSyncStatus("1");
+ // 【必填】来源标识:fd_sources,配置 thirdParty.hcs.src-system(约定值 WLTMS,D14)
+ props.setFdSources(hcsProperties.getSrcSystem());
+
+ // 金蝶项目立项ID 来源系统主键 ID:TMS 项目立项单主键(D14,对账/幂等候选键)
+ // props.setFdKingdeeId(String.valueOf(projectApply.getId()));
+
+ // 创建人:项目经办人登录名(扫描推送无登录上下文,取单据承办人,§9-U2)
+ props.setFdCreator(resolveUserRef(projectApply.getHandlerUserId()));
+ formData.setDynamicProps(props);
+ return formData;
+ }
+
+ /**
+ * 结算方式字典文本映射 HCS 编码:先款后票=1、先票后款=2、先款后票+先票后款(同时存在)=3;未匹配时原值直传并告警
+ */
+ private String mapSettlementType(String settlementMode) {
+ if (StringUtil.isBlank(settlementMode)) {
+ return null;
+ }
+ String code = switch (settlementMode.trim()) {
+ case "先款后票" -> "1";
+ case "先票后款" -> "2";
+ case "先款后票+先票后款(同时存在)" -> "3";
+ default -> null;
+ };
+ if (code == null) {
+ log.warn("HCS项目立项结算类型未匹配编码,原值直传:settlementMode=[{}]", settlementMode);
+ return settlementMode;
+ }
+ return code;
+ }
+
+ /**
+ * C102-3 项目立项查询判重(conditions={"fd_num": projectCode},§9-U4 查询能力联调确认)
+ */
+ private String findExistFdId(ProjectApply projectApply) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ // C102-3 入参 columns 用下划线字段名
+ query.setColumns(List.of(HcsFieldConstant.PROJ_FD_ID, HcsFieldConstant.PROJ_FD_NUM));
+ // 判重条件:项目编号精确匹配
+ query.setConditions(Map.of(HcsFieldConstant.PROJ_FD_NUM, Objects.toString(projectApply.getProjectCode(), "")));
+ query.setOffset(0);
+ query.setPageSize(10);
+ query.setSorts(Map.of(HcsFieldConstant.PROJ_FD_CREATE_TIME, "desc"));
+ // 查询需携带当前操作人(复用单据承办人登录名)
+ query.setCurUser(HcsUserRefVO.ofLoginName(requireLoginName(projectApply, "C102-3")));
+ HcsResult> result = hcsClient.projectList(query);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS项目立项判重查询失败响应:{},projectCode={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), projectApply.getProjectCode());
+ throw new HcsException("C102-3 项目立项判重查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsPageVO page = result.getData();
+ if (page == null || page.getContent() == null || page.getContent().isEmpty()) {
+ return null;
+ }
+ return page.getContent().get(0).getFdId();
+ }
+
+ /**
+ * 项目金蝶同步结果接收处理(合规 projectLegal/result 推送,D902):
+ * sourceSystem 须为 (不符抛"来源系统标识错误"异常);projectCode=TMS 项目编号定位(requestId 仅 X-ERP-Request-Id 回显不可定位);
+ * success=true 回填金蝶项目ID/编号(kingdeeId/kingdeeNumber);success=false 记录失败原因;原文留痕 blade_hcs_check_log
+ *
+ * @param param 合规推送报文
+ * @return 处理结果描述
+ */
+ public String handleLegalResult(HcsProjectLegalResultDTO param) {
+ // 来源系统校验:仅接受合规方约定的 ,防伪造回传
+ if (!StrUtil.equalsIgnoreCase(param == null ? null : param.getSourceSystem(), hcsProperties.getSrcSystem())) {
+ log.warn("项目金蝶同步结果推送来源系统标识错误:sourceSystem={}", param == null ? null : param.getSourceSystem());
+ throw new ServiceException("来源系统标识错误");
+ }
+ String raw = JSONUtil.toJsonStr(param);
+ ProjectApply projectApply = locateProjectByCode(param.getProjectCode());
+ if (projectApply == null) {
+ log.warn("项目金蝶同步结果推送未定位到项目:projectCode={},报文已留痕", param.getProjectCode());
+ writeCallbackLog(null, HcsSyncConstant.CHECK_RESULT_SKIP, "项目金蝶同步结果推送未定位到项目:" + raw);
+ return "已留痕:未定位到项目(projectCode=" + param.getProjectCode() + ")";
+ }
+ if (param.getSuccess() == null) {
+ log.warn("项目金蝶同步结果推送 success 字段缺失:projectCode={},报文已留痕", projectApply.getProjectCode());
+ writeCallbackLog(projectApply, HcsSyncConstant.CHECK_RESULT_SKIP, "项目金蝶同步结果推送 success 字段缺失:" + raw);
+ return "已留痕:success 字段缺失(projectCode=" + projectApply.getProjectCode() + ")";
+ }
+ if (param.getSuccess()) {
+ // 成功:回填金蝶项目ID/编号,同步结果信息随 hcs_sync_msg 留痕
+ projectApply.setKingdeeId(param.getKingdeeId());
+ projectApply.setKingdeeNumber(param.getProjectNumber());
+ projectApply.setHcsSyncMsg(HcsSyncConstant.truncateMsg("项目金蝶同步结果回调:金蝶同步成功,kingdeeId=" + param.getKingdeeId()
+ + ",projectNumber=" + param.getProjectNumber()));
+ appendKingdeeResultRecord(projectApply, "金蝶同步成功:金蝶项目ID=" + param.getKingdeeId()
+ + ",金蝶项目编号=" + param.getProjectNumber());
+ projectApplyMapper.updateById(projectApply);
+ writeCallbackLog(projectApply, HcsSyncConstant.CHECK_RESULT_PASS, "项目金蝶同步结果推送:" + raw);
+ log.info("项目金蝶同步结果推送处理完成:projectCode={},结果=success,kingdeeId={}", projectApply.getProjectCode(), param.getKingdeeId());
+ return "金蝶同步结果已处理:success";
+ }
+ // 失败:记录失败原因(金蝶同步在合规侧完成,TMS 不重推,失败重试由合规侧负责)
+ projectApply.setHcsSyncMsg(HcsSyncConstant.truncateMsg("项目金蝶同步结果回调:金蝶同步失败:" + param.getReason()));
+ appendKingdeeResultRecord(projectApply, "金蝶同步失败:" + param.getReason());
+ projectApplyMapper.updateById(projectApply);
+ writeCallbackLog(projectApply, HcsSyncConstant.CHECK_RESULT_FAIL, "项目金蝶同步结果推送:" + raw);
+ log.info("项目金蝶同步结果推送处理完成:projectCode={},结果=fail,reason={}", projectApply.getProjectCode(), param.getReason());
+ return "金蝶同步结果已处理:fail";
+ }
+
+ /**
+ * 金蝶同步结果回填变更记录(changeRecordJson 追加一条;系统回调无登录用户,操作人记来源标识)
+ */
+ private void appendKingdeeResultRecord(ProjectApply projectApply, String changeContent) {
+ List records = StringUtil.isBlank(projectApply.getChangeRecordJson())
+ ? new ArrayList<>() : new ArrayList<>(JSONUtil.toList(projectApply.getChangeRecordJson(), Object.class));
+ Map record = new LinkedHashMap<>();
+ record.put("changeDate", LocalDate.now().toString());
+ record.put("handlerUserName", "合规系统回调");
+ record.put("changeType", "金蝶同步结果");
+ record.put("changeContent", changeContent);
+ record.put("status", projectApply.getApprovalStatus());
+ record.put("statusName", HcsSyncConstant.approvalStatusName(projectApply.getApprovalStatus()));
+ records.add(record);
+ projectApply.setChangeRecordJson(JSONUtil.toJsonStr(records));
+ }
+
+ /**
+ * 按 ERP 项目编号定位项目(合规推送 projectCode,对应 C102 fd_num)
+ */
+ private ProjectApply locateProjectByCode(String projectCode) {
+ if (StringUtil.isBlank(projectCode)) {
+ return null;
+ }
+ return projectApplyMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(ProjectApply::getProjectCode, projectCode.trim())
+ .last("limit 1"));
+ }
+
+ /**
+ * 项目金蝶同步结果推送留痕(blade_hcs_check_log,biz_type=project,项目未定位到时 bizId 为空,异常不影响主流程)
+ */
+ private void writeCallbackLog(ProjectApply projectApply, String result, String msg) {
+ try {
+ HcsContractCheckLog checkLog = new HcsContractCheckLog();
+ if (projectApply != null) {
+ checkLog.setTenantId(projectApply.getTenantId());
+ checkLog.setBizId(projectApply.getId());
+ }
+ checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_PROJECT);
+ checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_KINGDEE);
+ checkLog.setResult(result);
+ checkLog.setMsg(StringUtil.left(msg, HcsSyncConstant.SYNC_MSG_MAX_LENGTH));
+ checkLog.setCreateTime(LocalDateTime.now());
+ hcsContractCheckLogMapper.insert(checkLog);
+ } catch (Exception exception) {
+ log.error("项目金蝶同步结果推送留痕写入失败:projectCode={}", projectApply == null ? null : projectApply.getProjectCode(), exception);
+ }
+ }
+
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
+ *
+ * @param id 项目立项单ID
+ * @return 推送结果描述
+ */
+ public String repushProject(Long id) {
+ ProjectApply projectApply = projectApplyMapper.selectById(id);
+ if (projectApply == null) {
+ throw new ServiceException("项目立项单不存在:" + id);
+ }
+ if (!List.of("approved", "change_approved").contains(projectApply.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的项目立项可推送合规(当前状态:" + projectApply.getApprovalStatus() + ")");
+ }
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ projectApply.setHcsRetryCount(0);
+ projectApplyMapper.updateById(projectApply);
+ try {
+ return pushProject(projectApply);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(projectApply, message);
+ throw new ServiceException("HCS项目立项推送失败:" + message);
+ }
+ }
+
+ /**
+ * 回填合规 fdId 并置 synced
+ */
+ public void markSynced(ProjectApply projectApply, String fdId, String message) {
+ projectApply.setHcsFdId(fdId);
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ projectApply.setHcsRetryCount(0);
+ projectApply.setHcsSyncTime(LocalDateTime.now());
+ projectApply.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ projectApplyMapper.updateById(projectApply);
+ log.info("HCS项目立项推送成功:projectCode={},fdId={},{}", projectApply.getProjectCode(), fdId, message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因(供扫描任务按 hcs_retry_count < max-retry 自动补偿)
+ */
+ public void markFailed(ProjectApply projectApply, String message) {
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ projectApply.setHcsRetryCount(projectApply.getHcsRetryCount() == null ? 1 : projectApply.getHcsRetryCount() + 1);
+ projectApply.setHcsSyncTime(LocalDateTime.now());
+ projectApply.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ projectApplyMapper.updateById(projectApply);
+ log.error("HCS项目立项推送失败:projectCode={},retryCount={},{}", projectApply.getProjectCode(), projectApply.getHcsRetryCount(), message);
+ }
+
+ /**
+ * 立项表单模板编码(未配置直接失败,提示配置项,§9-U9)
+ */
+ private String requireTemplateCode() {
+ String templateCode = hcsProperties.getProject().getTemplateCode();
+ if (StringUtil.isBlank(templateCode)) {
+ throw new HcsException("HCS项目立项推送失败,立项表单模板编码未配置(thirdParty.hcs.project.template-code,§9-U9)");
+ }
+ return templateCode;
+ }
+
+ /**
+ * 操作人登录名(curUser/fd_head/fd_taker/fd_creator 统一取配置,§9-U2)
+ */
+ private String requireLoginName(ProjectApply projectApply, String scene) {
+ return resolveLoginName(projectApply.getHandlerUserId());
+ }
+
+ /**
+ * 按用户ID 解析登录名引用(查不到返回 null,调用方按字段必填性兜底)
+ */
+ private HcsUserRefVO resolveUserRef(Long userId) {
+ String loginName = resolveLoginName(userId);
+ return StringUtil.isBlank(loginName) ? null : HcsUserRefVO.ofLoginName(loginName);
+ }
+
+ /**
+ * 操作人登录名(thirdParty.hcs.operator-login-name,默认 srvadmin)
+ */
+ private String resolveLoginName(Long userId) {
+ return hcsProperties.getOperatorLoginName();
+ }
+
+ /**
+ * TMS 万元 → HCS 元
+ */
+ private BigDecimal toYuan(BigDecimal wanAmount) {
+ return wanAmount == null ? null : wanAmount.multiply(WAN_TO_YUAN);
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptBillSyncSupport.java
new file mode 100644
index 00000000..525cc3cd
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReceiptBillSyncSupport.java
@@ -0,0 +1,301 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsMechanismsDTO;
+import org.springblade.hcs.dto.HcsReceiptBillFormDataDTO;
+import org.springblade.hcs.dto.HcsReceiptBillPropsDTO;
+import org.springblade.hcs.dto.HcsReceiptBillSaveDTO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.FormalSettlementMapper;
+import org.springblade.transport.mapper.InvoiceReceiptMapper;
+import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.FormalSettlement;
+import org.springblade.transport.pojo.entity.InvoiceReceipt;
+import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * HCS 收票单同步支撑组件(C109-1 新建 / C109-2 更新,设计文档 §5.8,D13)
+ *
+ * 审批通过 → C109-1;票据变更 → C109-2(顶层 fdId=hcs_fd_id 必填,只传变更字段、不带文档状态;
+ * v4 无作废字段,作废推送待联调确认)。合同关联:应付结算链路回溯(收票 → 应收/应付结算关联 → 结算单合同),
+ * 回溯不到时取页面人工补录的 hcs_contract_id/hcs_contract_no(D13)。
+ * fd_ap_bill_src_subject(认领应付单号键)待联调(§9-U11),一期不传。
+ * 返回双层嵌套 data.data.fdId → hcs_fd_id。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsReceiptBillSyncSupport {
+
+ /**
+ * 收票单附件字段标识
+ */
+ private static final String RECEIPT_ATTACH_ENTITY_KEY = "fd_attach";
+
+ /**
+ * 收票日期格式(fd_bill_date 为 YYYY-MM-DD 字符串,非毫秒时间戳)
+ */
+ private static final DateTimeFormatter BILL_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final InvoiceReceiptMapper invoiceReceiptMapper;
+ private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper;
+ private final FormalSettlementMapper formalSettlementMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+
+ /**
+ * 推送收票单:无合规ID 走 C109-1 新建,有合规ID 走 C109-2 更新;整体状态由调用方回写
+ *
+ * @param invoiceReceipt 收票单(approvalStatus=approved)
+ * @return 推送结果描述
+ */
+ public String pushReceiptBill(InvoiceReceipt invoiceReceipt) {
+ String operatorLoginName = resolveLoginName(invoiceReceipt);
+ if (StringUtil.isBlank(invoiceReceipt.getHcsFdId())) {
+ HcsReceiptBillSaveDTO param = buildSaveParam(invoiceReceipt, operatorLoginName, true);
+ log.info("HCS收票单新建开始:invoiceNo={}", invoiceReceipt.getInvoiceNo());
+ HcsResult> result = hcsClient.addReceiveBill(param);
+ String fdId = extractNestedFdId(invoiceReceipt, result);
+ // 收票单ID 立即持久化(调用方必须保存)
+ invoiceReceipt.setHcsFdId(fdId);
+ invoiceReceiptMapper.updateById(invoiceReceipt);
+ log.info("HCS收票单新建成功:invoiceNo={},fdId={}", invoiceReceipt.getInvoiceNo(), fdId);
+ return "C109-1 收票单新建成功";
+ }
+ // C109-2 更新:顶层 fdId 必填,只传变更字段、不带文档状态
+ HcsReceiptBillSaveDTO param = buildSaveParam(invoiceReceipt, operatorLoginName, false);
+ log.info("HCS收票单更新开始:invoiceNo={},fdId={}", invoiceReceipt.getInvoiceNo(), invoiceReceipt.getHcsFdId());
+ HcsResult> result = hcsClient.updateReceiveBill(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS收票单更新失败响应:{},invoiceNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), invoiceReceipt.getInvoiceNo());
+ throw new HcsException("C109-2 收票单更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return "C109-2 收票单更新成功";
+ }
+
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送
+ *
+ * @param id 收票单ID
+ * @return 推送结果描述
+ */
+ public String repushReceiptBill(Long id) {
+ InvoiceReceipt invoiceReceipt = invoiceReceiptMapper.selectById(id);
+ if (invoiceReceipt == null) {
+ throw new ServiceException("收票单不存在:" + id);
+ }
+ if (!"approved".equals(invoiceReceipt.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的收票单可推送合规(当前状态:" + invoiceReceipt.getApprovalStatus() + ")");
+ }
+ invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ invoiceReceipt.setHcsRetryCount(0);
+ invoiceReceiptMapper.updateById(invoiceReceipt);
+ try {
+ return pushReceiptBill(invoiceReceipt);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(invoiceReceipt, message);
+ throw new ServiceException("HCS收票单推送失败:" + message);
+ }
+ }
+
+ /**
+ * 组装 C109-1/C109-2 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.8)
+ */
+ private HcsReceiptBillSaveDTO buildSaveParam(InvoiceReceipt invoiceReceipt, String operatorLoginName, boolean add) {
+ HcsReceiptBillSaveDTO param = new HcsReceiptBillSaveDTO();
+ // 收票单主键:新建不传,更新必填(顶层 fdId)
+ param.setFdId(invoiceReceipt.getHcsFdId());
+ // 【必填】操作人登录名:收票单创建人登录名(§9-U2)
+ param.setFdLoginName(operatorLoginName);
+ HcsReceiptBillFormDataDTO formData = new HcsReceiptBillFormDataDTO();
+ HcsReceiptBillPropsDTO props = new HcsReceiptBillPropsDTO();
+ // 【必填】发票号码:invoiceNo
+ props.setFdInvoiceCode(invoiceReceipt.getInvoiceNo());
+ // 【必填】开票/收票日期:invoiceDate(YYYY-MM-DD 字符串)
+ props.setFdBillDate(formatBillDate(invoiceReceipt.getInvoiceDate()));
+ // 开票方(销方):issuerName
+ props.setFdInvoicer(invoiceReceipt.getIssuerName());
+ // 收票方(购方):receiverName
+ props.setFdBuyer(invoiceReceipt.getReceiverName());
+ // 【必填】价税合计金额:invoiceAmount
+ props.setFdInvoiceAmtTaxIncl(invoiceReceipt.getInvoiceAmount());
+ // 【必填】税率(小数):TMS taxRate(如 13)÷ 100 → 0.13
+ props.setFdTaxRate(toTaxRate(invoiceReceipt.getTaxRate()));
+ // 【必填】发票类型:receipt-bill.invoice-type-mapping 字典映射(§9-U3,未配置不传)
+ props.setFdInvoiceType(hcsProperties.getReceiptBill().getInvoiceTypeMapping().get(invoiceReceipt.getInvoiceType()));
+ // 【必填】合同主题/编号:应付结算链路回溯,回溯不到取人工补录(D13,均为空不传待联调兜底口径)
+ ContractReference contractReference = resolveContract(invoiceReceipt);
+ props.setFdContractSubject(contractReference.subject());
+ props.setFdContractNo(contractReference.contractNo());
+ // TODO 待联调(§9-U11):fd_ap_bill_src_subject 关联结算付款单的编号体系(应付单号键)v4 未明确,一期不传
+ // 备注:remark
+ props.setFdRemark(invoiceReceipt.getRemark());
+ formData.setDynamicProps(props);
+ param.setFormData(formData);
+ // 附件挂载(发票扫描件):fdEntityKey=fd_attach,发起带 fdBindType=ADD,更新不带
+ if (hcsProperties.getAttachment().isTransferEnabled() && StringUtil.isNotBlank(invoiceReceipt.getAttachmentsJson())) {
+ List attachmentRefs = hcsAttachmentSupport.transferAttachments(
+ HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, invoiceReceipt.getId(),
+ invoiceReceipt.getAttachmentsJson(), operatorLoginName);
+ if (!attachmentRefs.isEmpty()) {
+ HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
+ mechanisms.setAttachment(attachmentRefs.stream().map(ref -> {
+ // 附件ID(C103-2 转传返回)
+ ref.setFdEntityKey(RECEIPT_ATTACH_ENTITY_KEY);
+ // 绑定类型:仅发起传 ADD
+ ref.setFdBindType(add ? "ADD" : null);
+ return ref;
+ }).toList());
+ param.setMechanisms(mechanisms);
+ }
+ }
+ return param;
+ }
+
+ /**
+ * 解析 C109-1 双层嵌套返回 data.data.fdId
+ */
+ private String extractNestedFdId(InvoiceReceipt invoiceReceipt, HcsResult> result) {
+ if (result == null || !result.isSuccess() || result.getData() == null || !result.getData().isSuccess()
+ || result.getData().getData() == null || StringUtil.isBlank(result.getData().getData().getFdId())) {
+ log.error("HCS收票单新建失败响应:{},invoiceNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), invoiceReceipt.getInvoiceNo());
+ throw new HcsException("C109-1 收票单新建失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getData().getFdId();
+ }
+
+ /**
+ * 合同引用解析:收票 → 应收结算关联 → 结算单合同 → 合同已推送传合规ID,否则传人工补录值(D13)
+ */
+ private ContractReference resolveContract(InvoiceReceipt invoiceReceipt) {
+ // ① 人工补录兜底优先可直接用;先走链路回溯
+ InvoiceReceiptSettlement relation = invoiceReceiptSettlementMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(InvoiceReceiptSettlement::getInvoiceReceiptId, invoiceReceipt.getId())
+ .last("limit 1"));
+ if (relation != null && relation.getFormalSettlementId() != null) {
+ FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId());
+ if (settlement != null && settlement.getContractId() != null) {
+ ContractManage contractManage = contractManageMapper.selectById(settlement.getContractId());
+ if (contractManage != null) {
+ // 合同已推送传合规合同ID(fd_contract_subject 支持 ID 引用),编号传 TMS 合同编号
+ String subject = StringUtil.isNotBlank(contractManage.getHcsFdId()) ? contractManage.getHcsFdId() : contractManage.getContractName();
+ return new ContractReference(subject, contractManage.getContractNo());
+ }
+ }
+ }
+ // ② 回溯不到:页面人工补录(hcs_contract_id/hcs_contract_no,D13)
+ if (invoiceReceipt.getHcsContractId() != null) {
+ ContractManage contractManage = contractManageMapper.selectById(invoiceReceipt.getHcsContractId());
+ if (contractManage != null) {
+ String subject = StringUtil.isNotBlank(contractManage.getHcsFdId()) ? contractManage.getHcsFdId() : contractManage.getContractName();
+ return new ContractReference(subject, StringUtil.isNotBlank(invoiceReceipt.getHcsContractNo()) ? invoiceReceipt.getHcsContractNo() : contractManage.getContractNo());
+ }
+ }
+ if (StringUtil.isNotBlank(invoiceReceipt.getHcsContractNo())) {
+ return new ContractReference(null, invoiceReceipt.getHcsContractNo());
+ }
+ log.warn("HCS收票单合同关联缺失:invoiceNo={},应付结算回溯与人工补录均为空,fd_contract_* 不传", invoiceReceipt.getInvoiceNo());
+ return new ContractReference(null, null);
+ }
+
+ /**
+ * 回填整体同步状态置 synced
+ */
+ public void markSynced(InvoiceReceipt invoiceReceipt, String message) {
+ invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ invoiceReceipt.setHcsRetryCount(0);
+ invoiceReceipt.setHcsSyncTime(LocalDateTime.now());
+ invoiceReceipt.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ invoiceReceiptMapper.updateById(invoiceReceipt);
+ log.info("HCS收票单推送成功:invoiceNo={},{}", invoiceReceipt.getInvoiceNo(), message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因
+ */
+ public void markFailed(InvoiceReceipt invoiceReceipt, String message) {
+ invoiceReceipt.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ invoiceReceipt.setHcsRetryCount(invoiceReceipt.getHcsRetryCount() == null ? 1 : invoiceReceipt.getHcsRetryCount() + 1);
+ invoiceReceipt.setHcsSyncTime(LocalDateTime.now());
+ invoiceReceipt.setHcsSyncMsg(HcsSyncConstant.truncateMsg(message));
+ invoiceReceiptMapper.updateById(invoiceReceipt);
+ log.error("HCS收票单推送失败:invoiceNo={},retryCount={},{}", invoiceReceipt.getInvoiceNo(), invoiceReceipt.getHcsRetryCount(), message);
+ }
+
+ /**
+ * 操作人登录名(thirdParty.hcs.operator-login-name,默认 srvadmin,§9-U2)
+ */
+ private String resolveLoginName(InvoiceReceipt invoiceReceipt) {
+ return hcsProperties.getOperatorLoginName();
+ }
+
+ /**
+ * 收票日期格式化(fd_bill_date 为 YYYY-MM-DD 字符串)
+ */
+ private String formatBillDate(LocalDate invoiceDate) {
+ return invoiceDate == null ? null : invoiceDate.format(BILL_DATE_FORMATTER);
+ }
+
+ /**
+ * 税率换算:TMS 整数值(如 13)÷ 100 → 小数(0.13)
+ */
+ private BigDecimal toTaxRate(BigDecimal taxRate) {
+ return taxRate == null ? null : taxRate.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 合同引用(fd_contract_subject / fd_contract_no)
+ */
+ private record ContractReference(String subject, String contractNo) {
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReconSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReconSupport.java
new file mode 100644
index 00000000..b3c31662
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsReconSupport.java
@@ -0,0 +1,146 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.dto.HcsContractDetailQueryDTO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+import org.springblade.transport.constant.HcsFieldConstant;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 对账查询支撑组件(设计文档 §5.9/§8-P5,运维收尾)
+ *
+ * 按来源单据编号回查 HCS 侧数据(项目立项/合同/合同详情/开票登记),
+ * 供对账查询页与人工排查使用;返回 HCS 原始数据结构。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsReconSupport {
+
+ private final IHcsClient hcsClient;
+
+ /**
+ * 按项目编号回查 HCS 项目立项(C102-3)
+ *
+ * @param projectCode 项目编号
+ * @return HCS 项目立项列表
+ */
+ public HcsResult> reconProject(String projectCode) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ // C102-3 入参 columns 用下划线字段名
+ query.setColumns(List.of(HcsFieldConstant.PROJ_FD_ID, HcsFieldConstant.PROJ_FD_NUM, HcsFieldConstant.PROJ_FD_PRO_NAME));
+ // 项目编号精确匹配
+ query.setConditions(Map.of(HcsFieldConstant.PROJ_FD_NUM, Objects.toString(projectCode, "")));
+ query.setOffset(0);
+ query.setPageSize(50);
+ HcsResult> result = hcsClient.projectList(query);
+ return requireSuccess("项目立项对账", result);
+ }
+
+ /**
+ * 按合同编号回查 HCS 合同(C103-3)
+ *
+ * @param contractNo 合同编号
+ * @return HCS 合同列表
+ */
+ public HcsResult> reconContract(String contractNo) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ query.setColumns(List.of(HcsFieldConstant.CONTRACT_FD_ID, HcsFieldConstant.CONTRACT_FD_SUBJECT,
+ HcsFieldConstant.CONTRACT_FD_NUMBER, HcsFieldConstant.CONTRACT_FD_CATE_HIERARCHY_ID));
+ // 合同编号精确匹配(§9-U4 查询能力联调确认)
+ query.setConditions(Map.of(HcsFieldConstant.CONTRACT_FD_NUMBER, Objects.toString(contractNo, "")));
+ query.setOffset(0);
+ query.setPageSize(50);
+ HcsResult> result = hcsClient.contractList(query);
+ return requireSuccess("合同对账", result);
+ }
+
+ /**
+ * 合同详情回查(C103-4,fdId 与 fdNumber 二选一)
+ *
+ * v4 已定义详情响应字段表(HcsContractDetailVO),级联/集合字段内部结构以实际返回为准(设计文档 §5.9)
+ *
+ * @param fdId 合同ID(可空)
+ * @param fdNumber 合同编号(可空)
+ * @return HCS 合同详情
+ */
+ public HcsResult> reconContractDetail(String fdId, String fdNumber) {
+ if (StringUtil.isBlank(fdId) && StringUtil.isBlank(fdNumber)) {
+ throw new HcsException("合同详情对账失败,fdId 与 fdNumber 至少传一项");
+ }
+ HcsContractDetailQueryDTO query = new HcsContractDetailQueryDTO();
+ // 合同ID(与编号二选一)
+ query.setFdId(fdId);
+ // 合同编号(与ID二选一)
+ query.setFdNumber(fdNumber);
+ HcsResult> result = hcsClient.contractDetail(query);
+ return requireSuccess("合同详情对账", result);
+ }
+
+ /**
+ * 按申请单号回查 HCS 开票登记(C111-1)
+ *
+ * @param applicationNo 开票申请单号
+ * @return HCS 开票登记列表
+ */
+ public HcsResult> reconInvoiceRegistration(String applicationNo) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ query.setColumns(List.of(HcsFieldConstant.INVOICE_FD_ID, HcsFieldConstant.INVOICE_FD_NUMBER,
+ HcsFieldConstant.INVOICE_FD_BILL_DATE, HcsFieldConstant.INVOICE_FD_CONTRACT_NUMBER,
+ HcsFieldConstant.INVOICE_FD_INVOICE_AMOUNT, HcsFieldConstant.INVOICE_FD_STATUS));
+ // 单据编号精确匹配(§9-U4 查询能力联调确认)
+ query.setConditions(Map.of(HcsFieldConstant.INVOICE_FD_NUMBER, Objects.toString(applicationNo, "")));
+ query.setOffset(0);
+ query.setPageSize(50);
+ HcsResult> result = hcsClient.invoiceRegistrationList(query);
+ return requireSuccess("开票登记对账", result);
+ }
+
+ /**
+ * 校验查询结果并返回
+ */
+ private HcsResult> requireSuccess(String scene, HcsResult> result) {
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS{}查询失败响应:{}", scene, result == null ? "无响应"
+ : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()));
+ throw new HcsException("HCS" + scene + "失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result;
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java
new file mode 100644
index 00000000..6f4de144
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java
@@ -0,0 +1,234 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.support.hcs;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.hcs.dto.HcsAttachmentRefDTO;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsMechanismsDTO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsSpecPayFormDataDTO;
+import org.springblade.hcs.dto.HcsSpecPayPropsDTO;
+import org.springblade.hcs.dto.HcsSpecPaySaveDTO;
+import org.springblade.hcs.exception.HcsException;
+import org.springblade.hcs.feign.IHcsClient;
+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.PaymentApplication;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * HCS 结算付款特批同步支撑组件(C106-3 发起 / C106-4 更新,设计文档 §5.5)
+ *
+ * 触发:付款申请提交时收款方外部客商高风险/黑名单(须特批附件)或额度超额标记 hcs_spec_status=need
+ * → 审批通过后扫描任务发起 C106-3,返回 data.data.fdId(双层嵌套)→ hcs_spec_fd_id,状态置 processing
+ * 并挂起 currentNode="特批审核";
+ * 单据变更且特批流程存在时走 C106-4(顶层 fdId=hcs_spec_fd_id,附件不带 fdBindType)。
+ * 特批结果:合规 paymentLegal/result 回调联动付款审批状态(通过放行/驳回联动审批驳回),
+ * 页面人工确认等效兜底(§9-U1)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsSpecPaySyncSupport {
+
+ /**
+ * 特批附件字段标识
+ */
+ private static final String SPEC_ATTACH_ENTITY_KEY = "fd_spc_approval_attach";
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ContractManageMapper contractManageMapper;
+ private final PaymentApplicationMapper paymentApplicationMapper;
+ private final HcsAttachmentSupport hcsAttachmentSupport;
+
+ /**
+ * 推送结算付款特批:未发起走 C106-3,已发起走 C106-4;流程ID 与状态立即持久化
+ *
+ * @param payment 付款申请单(hcs_spec_status=need)
+ * @return 推送结果描述
+ */
+ public String pushSpecPay(PaymentApplication payment) {
+ String operatorLoginName = resolveLoginName(payment);
+ HcsSpecPaySaveDTO param = buildSpecPayParam(payment, operatorLoginName);
+ if (StringUtil.isBlank(payment.getHcsSpecFdId())) {
+ // C106-3 发起:返回双层嵌套 data.data.fdId
+ log.info("HCS结算付款特批发起开始:paymentNo={}", payment.getPaymentNo());
+ HcsResult> result = hcsClient.addSpecApplyPay(param);
+ String specFdId = extractNestedFdId(payment, result);
+ payment.setHcsSpecFdId(specFdId);
+ payment.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_PROCESSING);
+ // TMS 流程挂起展示:等待合规 paymentLegal/result 回调联动付款审批状态(与合同链路 hangSpecReview 口径一致)
+ payment.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE);
+ paymentApplicationMapper.updateById(payment);
+ log.info("HCS结算付款特批发起成功:paymentNo={},specFdId={},挂起「{}」", payment.getPaymentNo(), specFdId, HcsSyncConstant.SPEC_REVIEW_NODE);
+ return "C106-3 特批发起成功";
+ }
+ // C106-4 更新:顶层 fdId 必填,内层无业务数据,以内层 success 判断
+ log.info("HCS结算付款特批更新开始:paymentNo={},specFdId={}", payment.getPaymentNo(), payment.getHcsSpecFdId());
+ HcsResult> result = hcsClient.updateSpecApplyPay(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS结算付款特批更新失败响应:{},paymentNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo());
+ throw new HcsException("C106-4 特批更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return "C106-4 特批更新成功";
+ }
+
+ /**
+ * 组装 C106-3/C106-4 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.5)
+ */
+ private HcsSpecPaySaveDTO buildSpecPayParam(PaymentApplication payment, String operatorLoginName) {
+ ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId());
+ HcsSpecPaySaveDTO param = new HcsSpecPaySaveDTO();
+ // 结算付款流程ID:发起不传,更新必填(C106-4 顶层 fdId)
+ param.setFdId(payment.getHcsSpecFdId());
+ // 【必填】操作人登录名:付款申请创建人登录名(§9-U2)
+ param.setFdLoginName(operatorLoginName);
+ // 【必填】流程模板ID:公司流程模板(spec-pay.template-mapping 按租户配置,环境/公司相关)
+ param.setFdTemplateId(requireTemplateId(payment));
+ HcsSpecPayFormDataDTO formData = new HcsSpecPayFormDataDTO();
+ HcsSpecPayPropsDTO props = new HcsSpecPayPropsDTO();
+ // 【必填】合同:传合规合同ID(blade_contract_manage.hcs_fd_id,非名称)
+ props.setFdContractName(requireContractFdId(payment, contractManage));
+ // 合同编号
+ props.setFdContractNo(payment.getContractNo());
+ // 【必填】单据编号:付款申请单号(来源付款单标识)
+ props.setFdDocNo(payment.getPaymentNo());
+ // 【必填】单据主题:"结算付款特批-" + 付款单号
+ props.setFdDocSubject("结算付款特批-" + payment.getPaymentNo());
+ // 来源付款单ID:付款申请单号(§5.5 映射表)
+ props.setFdId(payment.getPaymentNo());
+ // 【必填】付款金额:appliedAmount
+ props.setFdPayAmount(payment.getAppliedAmount());
+ // 【必填】合计金额:settlementAmount
+ props.setFdTotalAmount(payment.getSettlementAmount() == null ? BigDecimal.ZERO : payment.getSettlementAmount());
+ // 【必填】付款人 / 收款方
+ props.setFdPayer(payment.getPayerName());
+ props.setFdPayee(payment.getPayeeName());
+ // 【必填】收款账户名 / 收款账号 / 开户行名称
+ props.setFdReceiveAccountName(payment.getReceiptAccountName());
+ props.setFdReceiveAccount(payment.getBankAccount());
+ props.setFdBankName(payment.getBankName());
+ // 【必填】结算币种:TMS 无币种字段,按 currency-name-mapping 默认值(人民币)
+ props.setFdSettleCurrency(hcsProperties.getContract().getCurrencyNameMapping().getOrDefault("RMB", "人民币"));
+ // 【必填】付款用途 / 款项性质 / 款项类型:字典值配置(§9-U3 待联调后配置)
+ props.setFdPaymentPurpose(hcsProperties.getSpecPay().getPaymentPurpose());
+ props.setFdPaymentNature(hcsProperties.getSpecPay().getPaymentNature());
+ props.setFdPaymentType(hcsProperties.getSpecPay().getPaymentType());
+ // 【必填】事由 / 补充说明:remark
+ props.setFdReason(payment.getRemark());
+ props.setFdSupplementNote(payment.getRemark());
+ // 单据状态:配置 spec-pay.doc-status(示例 10)
+ props.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus());
+ // 特批附件字段标识:固定 fd_spc_approval_attach
+ props.setFdSpcApprovalAttach(SPEC_ATTACH_ENTITY_KEY);
+ formData.setDynamicProps(props);
+ // 【必填】表单层单据主题
+ formData.setFdDocSubject("结算付款特批-" + payment.getPaymentNo());
+ // 表单层单据状态:配置 spec-pay.doc-status
+ formData.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus());
+ param.setFormData(formData);
+ // 附件挂载:attachmentsJson 经 C103-2 转传,特批附件 fdEntityKey=fd_spc_approval_attach,
+ // 发起带 fdBindType=ADD,更新不带
+ param.setMechanisms(buildMechanisms(payment, operatorLoginName));
+ return param;
+ }
+
+ /**
+ * 特批附件挂载(附件转传开关关闭或无附件时返回 null)
+ */
+ private HcsMechanismsDTO buildMechanisms(PaymentApplication payment, String operatorLoginName) {
+ if (!hcsProperties.getAttachment().isTransferEnabled() || StringUtil.isBlank(payment.getAttachmentsJson())) {
+ return null;
+ }
+ List attachmentRefs = hcsAttachmentSupport.transferAttachments(
+ HcsSyncConstant.BIZ_TYPE_PAYMENT, payment.getId(), payment.getAttachmentsJson(), operatorLoginName);
+ if (attachmentRefs.isEmpty()) {
+ return null;
+ }
+ HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
+ boolean add = StringUtil.isBlank(payment.getHcsSpecFdId());
+ mechanisms.setAttachment(attachmentRefs.stream().map(ref -> {
+ // 附件ID(C103-2 转传返回)
+ ref.setFdEntityKey(SPEC_ATTACH_ENTITY_KEY);
+ // 绑定类型:仅发起传 ADD
+ ref.setFdBindType(add ? "ADD" : null);
+ return ref;
+ }).toList());
+ return mechanisms;
+ }
+
+ /**
+ * 解析 C106-3 双层嵌套返回 data.data.fdId
+ */
+ private String extractNestedFdId(PaymentApplication payment, HcsResult> result) {
+ if (result == null || !result.isSuccess() || result.getData() == null || !result.getData().isSuccess()
+ || result.getData().getData() == null || StringUtil.isBlank(result.getData().getData().getFdId())) {
+ log.error("HCS结算付款特批发起失败响应:{},paymentNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), payment.getPaymentNo());
+ throw new HcsException("C106-3 特批发起失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getData().getFdId();
+ }
+
+ /**
+ * 付款特批流程模板ID(fdTemplateId 必填,spec-pay.template-id,每个环境/公司不同)
+ */
+ private String requireTemplateId(PaymentApplication payment) {
+ String templateId = hcsProperties.getSpecPay().getTemplateId();
+ if (StringUtil.isBlank(templateId)) {
+ throw new HcsException("C106 特批推送失败,未配置付款特批流程模板ID(thirdParty.hcs.spec-pay.template-id)");
+ }
+ return templateId;
+ }
+
+ /**
+ * 合规合同ID(fd_contract_name 必填,合同未推送时失败提示)
+ */
+ private String requireContractFdId(PaymentApplication payment, ContractManage contractManage) {
+ if (contractManage == null || StringUtil.isBlank(contractManage.getHcsFdId())) {
+ throw new HcsException("C106 特批推送失败,关联合同尚未推送合规(paymentNo=" + payment.getPaymentNo() + ")");
+ }
+ return contractManage.getHcsFdId();
+ }
+
+ /**
+ * 操作人登录名(thirdParty.hcs.operator-login-name,默认 srvadmin,§9-U2)
+ */
+ private String resolveLoginName(PaymentApplication payment) {
+ return hcsProperties.getOperatorLoginName();
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java
index 24d41f7e..02127d66 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java
@@ -29,6 +29,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.service.ICustomerArchiveService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -47,6 +49,7 @@ import org.springframework.stereotype.Component;
*/
@Slf4j
@Component
+@RefreshScope
@RequiredArgsConstructor
public class CustomerKingdeeSyncTask {
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java
new file mode 100644
index 00000000..874fc41f
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java
@@ -0,0 +1,352 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.task;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.redis.cache.BladeRedis;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.hcs.config.HcsProperties;
+import org.springblade.transport.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ContractManageMapper;
+import org.springblade.transport.mapper.InvoiceApplicationMapper;
+import org.springblade.transport.mapper.InvoiceReceiptMapper;
+import org.springblade.transport.mapper.PaymentApplicationMapper;
+import org.springblade.transport.mapper.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.InvoiceApplication;
+import org.springblade.transport.pojo.entity.InvoiceReceipt;
+import org.springblade.transport.pojo.entity.PaymentApplication;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport;
+import org.springblade.transport.support.hcs.HcsContractSyncSupport;
+import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
+import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
+import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
+import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+/**
+ * HCS 合规推送定时扫描任务(设计文档 §5.0,ADR-0001)
+ *
+ * 审批代码零埋点:扫描只认 approvalStatus=approved/change_approved 且 hcs_sync_status=unsynced/failed(重试未超限),
+ * 与审批入口(/approve、MK 回调)无关;失败落 failed 由下轮扫描自动补偿,超限仅人工重推(D7)。
+ *
+ * 五类任务各自独立调度、独立开关(Nacos thirdParty.hcs.push.{bizType}.*):
+ *
+ * bizType 说明 配置节点
+ * project 项目立项 C102 thirdParty.hcs.push.project.*
+ * contract 合同 C103(内含风险校验/附件转传/履约规则) thirdParty.hcs.push.contract.*
+ * payment 付款特批 C106-3 + 履约记录 C108 thirdParty.hcs.push.payment.*
+ * invoice-application 开票登记 C111(含金蝶开票回填) thirdParty.hcs.push.invoice-application.*
+ * invoice-receipt 收票单 C109 thirdParty.hcs.push.invoice-receipt.*
+ *
+ * 每个任务配置项:sync-enabled 开关(默认关,灰度打开)、cron 调度(默认每 5 分钟,各任务分钟位错峰
+ * 0/1/2/3/4,避免同刻并发扫描与 HCS 接口流量尖峰);batch-size/max-retry 为全局配置(thirdParty.hcs.push.*),
+ * 见 {@link HcsProperties.Push}。
+ * 多实例部署时按任务隔离 Redis 分布式锁防重扫(setIfAbsent + 持有值比对释放,TTL 240s),任务间互不阻塞。
+ * 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts/pushUnsyncedPayments 等方法,供手动端点(HcsSyncController)复用。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RefreshScope
+@RequiredArgsConstructor
+public class HcsPushTask {
+
+ /**
+ * 分布式锁 TTL(秒):须小于各任务 cron 间隔(默认 5 分钟)且大于单轮预期执行时长,实例崩溃后锁自动过期
+ */
+ private static final long PUSH_TASK_LOCK_TTL_SECONDS = 240L;
+
+ /**
+ * 审批通过状态集合(含变更通过)
+ */
+ private static final List APPROVED_STATUSES = List.of("approved", "change_approved");
+
+ private final HcsProperties hcsProperties;
+ private final ProjectApplyMapper projectApplyMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final PaymentApplicationMapper paymentApplicationMapper;
+ private final InvoiceApplicationMapper invoiceApplicationMapper;
+ private final InvoiceReceiptMapper invoiceReceiptMapper;
+ private final HcsProjectSyncSupport hcsProjectSyncSupport;
+ private final HcsContractSyncSupport hcsContractSyncSupport;
+ private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport;
+ private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
+ private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
+ private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
+ private final BladeRedis bladeRedis;
+
+ /**
+ * 项目立项(C102)定时推送:扫描审批通过且待同步/待补偿的项目,独立开关与 cron 调度
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.project.cron:0 */5 * * * ?}")
+ public void pushHcsProject() {
+ this.runOnce("项目立项", HcsSyncConstant.BIZ_TYPE_PROJECT, hcsProperties.getPush().getProject().isSyncEnabled(), this::pushUnsyncedProjects);
+ }
+
+ /**
+ * 合同(C103)定时推送:扫描审批通过且待同步/待补偿的合同(含风险校验→附件转传→C103-1→履约规则 C103-9 行级同步),
+ * 并对特批中(processing)的合同做 C103-4 结果查询兜底,独立开关与 cron 调度
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.contract.cron:0 1/5 * * * ?}")
+ public void pushHcsContract() {
+ this.runOnce("合同", HcsSyncConstant.BIZ_TYPE_CONTRACT, hcsProperties.getPush().getContract().isSyncEnabled(), this::syncHcsContracts);
+ }
+
+ /**
+ * 合同链路组合动作:待同步合同推送 + 特批结果查询兜底(合规推送 contractLegal/result 未达时 C103-4 轮询联动)
+ */
+ private String syncHcsContracts() {
+ String pushSummary = this.pushUnsyncedContracts();
+ return pushSummary + ";" + hcsContractSpecSyncSupport.syncProcessingSpecResults();
+ }
+
+ /**
+ * 付款链(C106 特批 / C108 履约记录)定时推送:扫描审批通过且待同步/待补偿的付款申请,独立开关与 cron 调度
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.payment.cron:0 2/5 * * * ?}")
+ public void pushHcsPayment() {
+ this.runOnce("付款链", HcsSyncConstant.BIZ_TYPE_PAYMENT, hcsProperties.getPush().getPayment().isSyncEnabled(), this::pushUnsyncedPayments);
+ }
+
+ /**
+ * 开票登记(C111)定时推送:扫描审批通过且待同步/待补偿的开票申请(含金蝶开票完成后回填),独立开关与 cron 调度
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.invoice-application.cron:0 3/5 * * * ?}")
+ public void pushHcsInvoiceApplication() {
+ this.runOnce("开票登记", HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, hcsProperties.getPush().getInvoiceApplication().isSyncEnabled(), this::pushUnsyncedInvoiceApplications);
+ }
+
+ /**
+ * 收票单(C109)定时推送:扫描审批通过且待同步/待补偿的收票单,独立开关与 cron 调度
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.invoice-receipt.cron:0 4/5 * * * ?}")
+ public void pushHcsInvoiceReceipt() {
+ this.runOnce("收票单", HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, hcsProperties.getPush().getInvoiceReceipt().isSyncEnabled(), this::pushUnsyncedInvoiceReceipts);
+ }
+
+ /**
+ * 单任务执行模板:开关校验 → 分布式锁抢锁 → 执行推送 → 持有值比对后释放。
+ * 锁按业务类型隔离,各任务可并行调度互不阻塞;TTL 见 {@link #PUSH_TASK_LOCK_TTL_SECONDS}。
+ *
+ * @param taskName 任务中文名(用于日志定位)
+ * @param bizType 业务类型(同时作为锁 key 后缀)
+ * @param syncEnabled 该任务的独立开关(thirdParty.hcs.push.{bizType}.sync-enabled)
+ * @param pushAction 推送动作,返回结果汇总描述
+ */
+ private void runOnce(String taskName, String bizType, boolean syncEnabled, Supplier pushAction) {
+ if (!syncEnabled) {
+ // 开关未开启属常态(灰度),debug 级别避免轮询刷屏
+ // log.debug("HCS{}推送任务未开启(thirdParty.hcs.push.{}.sync-enabled),本轮跳过", taskName, bizType);
+ return;
+ }
+ // 多实例部署防重扫:setIfAbsent 抢锁,持有值比对后释放
+ String lockKey = HcsSyncConstant.PUSH_TASK_LOCK_KEY_PREFIX + bizType;
+ String lockHolder = UUID.randomUUID().toString();
+ Boolean locked = bladeRedis.getValueOps()
+ .setIfAbsent(lockKey, lockHolder, Duration.ofSeconds(PUSH_TASK_LOCK_TTL_SECONDS));
+ if (!Boolean.TRUE.equals(locked)) {
+ log.info("HCS{}推送任务其他实例执行中(lockKey={}),本轮跳过", taskName, lockKey);
+ return;
+ }
+ try {
+ log.info("HCS{}推送任务开始执行", taskName);
+ String summary = pushAction.get();
+ log.info("HCS{}推送任务执行完成:{}", taskName, summary);
+ } finally {
+ if (lockHolder.equals(bladeRedis.get(lockKey))) {
+ bladeRedis.del(lockKey);
+ }
+ }
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的项目立项(C102),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedProjects() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List projectList = projectApplyMapper.selectList(Wrappers.lambdaQuery()
+ .in(ProjectApply::getApprovalStatus, APPROVED_STATUSES)
+ .in(ProjectApply::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(ProjectApply::getHcsRetryCount).or().lt(ProjectApply::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (ProjectApply projectApply : projectList) {
+ try {
+ hcsProjectSyncSupport.pushProject(projectApply);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsProjectSyncSupport.markFailed(projectApply, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", projectList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS项目立项推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的合同(C103),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedContracts() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List contractList = contractManageMapper.selectList(Wrappers.lambdaQuery()
+ .in(ContractManage::getApprovalStatus, APPROVED_STATUSES)
+ .in(ContractManage::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(ContractManage::getHcsRetryCount).or().lt(ContractManage::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (ContractManage contractManage : contractList) {
+ try {
+ hcsContractSyncSupport.pushContract(contractManage);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsContractSyncSupport.markFailed(contractManage, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", contractList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS合同推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的付款申请(C106 特批 + C108 履约记录),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedPayments() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List paymentList = paymentApplicationMapper.selectList(Wrappers.lambdaQuery()
+ .eq(PaymentApplication::getApprovalStatus, "approved")
+ .in(PaymentApplication::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(PaymentApplication::getHcsRetryCount).or().lt(PaymentApplication::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (PaymentApplication payment : paymentList) {
+ try {
+ hcsPaymentSyncSupport.pushPayment(payment);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsPaymentSyncSupport.markFailed(payment, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", paymentList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS付款链推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的开票申请(C111 开票登记,含金蝶开票完成后回填),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedInvoiceApplications() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List applicationList = invoiceApplicationMapper.selectList(Wrappers.lambdaQuery()
+ .eq(InvoiceApplication::getApprovalStatus, "approved")
+ .in(InvoiceApplication::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(InvoiceApplication::getHcsRetryCount).or().lt(InvoiceApplication::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (InvoiceApplication application : applicationList) {
+ try {
+ hcsInvoiceRegSyncSupport.pushInvoiceRegistration(application);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsInvoiceRegSyncSupport.markFailed(application, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", applicationList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS开票登记推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的收票单(C109),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedInvoiceReceipts() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List receiptList = invoiceReceiptMapper.selectList(Wrappers.lambdaQuery()
+ .eq(InvoiceReceipt::getApprovalStatus, "approved")
+ .in(InvoiceReceipt::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(InvoiceReceipt::getHcsRetryCount).or().lt(InvoiceReceipt::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (InvoiceReceipt invoiceReceipt : receiptList) {
+ try {
+ hcsReceiptBillSyncSupport.pushReceiptBill(invoiceReceipt);
+ hcsReceiptBillSyncSupport.markSynced(invoiceReceipt, "C109 收票单推送成功");
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsReceiptBillSyncSupport.markFailed(invoiceReceipt, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", receiptList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS收票单推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java
index 29f0e88a..e5e4fb87 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java
@@ -26,6 +26,8 @@ import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.service.IPaymentApplicationService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -47,6 +49,7 @@ import org.springframework.stereotype.Component;
*/
@Slf4j
@Component
+@RefreshScope
@RequiredArgsConstructor
public class PaymentKingdeeResultTask {
diff --git a/blade-third-party-api/blade-hcs-api/pom.xml b/blade-third-party-api/blade-hcs-api/pom.xml
new file mode 100644
index 00000000..ff5c4ad2
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/pom.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+
+ org.springblade
+ blade-third-party-api
+ ${revision}
+
+
+ blade-hcs-api
+ ${project.artifactId}
+ jar
+ BladeX 合同合规系统(HCS)API 契约模块
+
+
+
+ org.springblade
+ blade-core-tool
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java
new file mode 100644
index 00000000..122c083c
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsFeignClientConfig.java
@@ -0,0 +1,81 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.config;
+
+import feign.Request;
+import feign.RequestInterceptor;
+import org.springblade.hcs.feign.HcsClientFallbackFactory;
+import org.springblade.hcs.interceptor.HcsAuthInterceptor;
+import org.springframework.context.annotation.Bean;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 合同合规系统(HCS)Feign 客户端配置
+ *
+ * 仅通过 {@link org.springframework.cloud.openfeign.FeignClient#configuration} 引用,避免拦截器全局生效。
+ * 按 D7 约定不配置 Retryer:写接口重试会造成 HCS 重复建单,重试职责归扫描任务(hcs_retry_count 补偿)。
+ *
+ * @author Chill
+ */
+public class HcsFeignClientConfig {
+
+ @Bean
+ public RequestInterceptor bladeFeignRequestInterceptor() {
+ // 空实现覆盖全局 BladeFeignRequestInterceptor,防止其覆盖 HCS 的认证凭据
+ return template -> {
+ };
+ }
+
+ /**
+ * HCS 降级工厂:非 2xx 时还原远程错误体 msg/traceId,避免全局兜底吞成 null(配合 IHcsClient 的 fallbackFactory 声明)
+ */
+ @Bean
+ public HcsClientFallbackFactory hcsClientFallbackFactory() {
+ return new HcsClientFallbackFactory();
+ }
+
+ /**
+ * HCS 认证拦截器:所有请求注入 Authorization + Key-Auth 两个静态认证头(v4 接口说明)
+ */
+ @Bean
+ public RequestInterceptor hcsAuthInterceptor(HcsProperties properties) {
+ return new HcsAuthInterceptor(properties);
+ }
+
+ @Bean
+ public feign.Logger.Level feignLoggerLevel() {
+ return feign.Logger.Level.FULL;
+ }
+
+ /**
+ * 超时:connect 10s / read 60s(照 BfiFeignClientConfig,D7)
+ */
+ @Bean
+ public Request.Options options() {
+ return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
+ }
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java
new file mode 100644
index 00000000..dc2b4b2a
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java
@@ -0,0 +1,348 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.config;
+
+import lombok.Data;
+import org.springblade.hcs.dto.HcsRefVO;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 合同合规系统(HCS)配置类
+ *
+ * Nacos 配置段见 third-party-api.yaml 的 thirdParty.hcs 节点:
+ * 注解前缀按 Spring Boot 规范使用横线小写形式 third-party.hcs(照 BfiProperties 惯例),
+ * YAML 中的驼峰键 thirdParty.hcs.* 经宽松绑定归一后等价生效。
+ *
+ * @author Chill
+ */
+@Data
+@ConfigurationProperties(prefix = "third-party.hcs")
+public class HcsProperties {
+ /**
+ * 合同合规系统(HCS)接口基础地址,直连可配(网关/iPaaS 部署时改此处,代码不感知)。
+ * v4 接口说明:地址带网关前缀,如 http://172.16.203.134:8000/gwzh/HTHG
+ */
+ private String baseUrl;
+ /**
+ * Authorization 请求头整值(含 Basic 前缀,如 Basic d2Njazp...,凭据走 Nacos + 环境变量注入)
+ */
+ private String authorization;
+ /**
+ * Key-Auth 请求头值(v4 接口说明:所有接口固定携带 Authorization + Key-Auth 两个头)
+ */
+ private String keyAuth;
+ /**
+ * 来源系统标识(fd_sources,D14 约定值 TMS)
+ */
+ private String srcSystem = "WLTMS";
+ /**
+ * 操作人登录名(fdLoginName / curUser.fdLoginName,全部合同接口共用,§9-U2)
+ */
+ private String operatorLoginName = "srvadmin";
+ /**
+ * Mock 开关(thirdParty.hcs.mockEnabled):true 时 IHcsClient 切换为本地 JSON 桩
+ * ({mockDir}/{方法名}.json,见 {@link org.springblade.hcs.mock.HcsClientMock}),
+ * 供联调前本地自测;生产环境必须为 false
+ */
+ private boolean mockEnabled = false;
+ /**
+ * Mock 桩文件目录(与 mockEnabled 配套):存放各接口出参桩 {方法名}.json(源自 HCS 接口文档出参示例),
+ * 每次调用现读,改文件即生效;默认本机开发目录,其他环境经 Nacos 按需覆盖
+ */
+ private String mockDir = "F:/home/tms/mock/hcs";
+ /**
+ * 创建单位 fdId(HCS 组织主数据固定值,fd_creator_unit 等组织引用,各合同接口共用)
+ */
+ private String creatorUnitId = "1j56etj23w358wne0ow2k5214nk4f24213w0";
+ /**
+ * 创建单位名称(与 creatorUnitId 配套,HCS 组织主数据固定值,各合同接口共用)
+ */
+ private String creatorUnitName = "广西物产城矿发展有限公司";
+ /**
+ * 推送任务配置(扫描首推/补偿)
+ */
+ private Push push = new Push();
+ /**
+ * 项目立项(C102)配置
+ */
+ private Project project = new Project();
+ /**
+ * 合同(C103)配置
+ */
+ private Contract contract = new Contract();
+ /**
+ * 履约规则(C103-9)配置
+ */
+ private ExeStage exeStage = new ExeStage();
+ /**
+ * 客商风险校验(C101-1)配置
+ */
+ private Risk risk = new Risk();
+ /**
+ * 额度校验(C106-1)配置
+ */
+ private Credit credit = new Credit();
+ /**
+ * 结算付款特批(C106-3/4)配置
+ */
+ private SpecPay specPay = new SpecPay();
+ /**
+ * 履约记录(C108-1/2)配置
+ */
+ private PerfRecord perfRecord = new PerfRecord();
+ /**
+ * 开票登记(C111)配置
+ */
+ private Invoice invoice = new Invoice();
+ /**
+ * 收票单(C109)配置
+ */
+ private ReceiptBill receiptBill = new ReceiptBill();
+ /**
+ * 附件转传(C103-2)配置
+ */
+ private Attachment attachment = new Attachment();
+ /**
+ * 主数据缓存(C103-6/7/12)配置
+ */
+ private MasterData masterData = new MasterData();
+
+ /**
+ * 创建单位引用解析:fdId+fdName 配置优先(third-party.hcs.creator-unit-id/name),
+ * 任一未配置回退单据部门名称仅传 fdName(各合同接口共用)
+ *
+ * @param fallbackName 单据自身部门名称(如开题部门名称)
+ * @return 创建单位引用
+ */
+ public HcsRefVO resolveCreatorUnit(String fallbackName) {
+ if (creatorUnitId == null || creatorUnitId.isEmpty() || creatorUnitName == null || creatorUnitName.isEmpty()) {
+ return HcsRefVO.ofName(fallbackName);
+ }
+ HcsRefVO ref = new HcsRefVO();
+ ref.setFdId(creatorUnitId);
+ ref.setFdName(creatorUnitName);
+ return ref;
+ }
+
+ @Data
+ public static class Push {
+ /**
+ * 审批通过即时推送总开关(thirdParty.hcs.push.immediate-enabled,默认关闭灰度打开;关闭时完全回落扫描链路)
+ */
+ private boolean immediateEnabled = false;
+ /**
+ * 每类单据单轮扫描批量(全局)
+ */
+ private int batchSize = 50;
+ /**
+ * 推送失败自动重试上限,超限仅人工重推(全局)
+ */
+ private int maxRetry = 5;
+ /**
+ * 项目立项(C102)推送开关(thirdParty.hcs.push.project.sync-enabled,默认关闭,灰度打开)
+ */
+ private Task project = new Task();
+ /**
+ * 合同(C103)推送开关(thirdParty.hcs.push.contract.sync-enabled,默认关闭,灰度打开)
+ */
+ private Task contract = new Task();
+ /**
+ * 付款链(C106 特批 / C108 履约记录)推送开关(thirdParty.hcs.push.payment.sync-enabled,默认关闭,灰度打开)
+ */
+ private Task payment = new Task();
+ /**
+ * 开票登记(C111)推送开关(thirdParty.hcs.push.invoice-application.sync-enabled,默认关闭,灰度打开)
+ */
+ private Task invoiceApplication = new Task();
+ /**
+ * 收票单(C109)推送开关(thirdParty.hcs.push.invoice-receipt.sync-enabled,默认关闭,灰度打开)
+ */
+ private Task invoiceReceipt = new Task();
+ }
+
+ /**
+ * 单类单据推送任务配置(thirdParty.hcs.push.{bizType}.sync-enabled;
+ * cron 有代码默认值错峰每 5 分钟,保持 @Scheduled 占位符形式,未纳入本类)
+ */
+ @Data
+ public static class Task {
+ /**
+ * 推送开关,默认关闭(灰度打开)
+ */
+ private boolean syncEnabled = false;
+ }
+
+ @Data
+ public static class Project {
+ /**
+ * 立项 bpmOperation.templateCode(环境相关值,§9-U9,待合规方提供)
+ */
+ private String templateCode = "";
+ }
+
+ @Data
+ public static class Contract {
+ /**
+ * TMS 合同类别 -> HCS 模板ID(fdTemplateId,§9-U7 待合规方提供模板清单)
+ */
+ private Map templateMapping = new HashMap<>();
+ /**
+ * TMS 审核状态 -> HCS 合同状态(xformFields CONTRACT_STATUS 必填,draft/approving/approved 等,§9-U3 待联调后配置)
+ */
+ private Map statusMapping = new HashMap<>();
+ /**
+ * TMS 币种码 -> HCS 币种名称(照金蝶 RMB->CNY 映射惯例)
+ */
+ private Map currencyNameMapping = new HashMap<>(Map.of("RMB", "人民币"));
+ /**
+ * 合同 fdSepcailContract 固定 false(D15,配置项预留)
+ */
+ private boolean specialContractEnabled = false;
+ /**
+ * 合同推送前是否强制客商风险校验
+ */
+ private boolean requireRiskCheck = true;
+ }
+
+ @Data
+ public static class ExeStage {
+ /**
+ * 履约规则同步开关(C103-9 行级同步,一期暂不对接,默认关)
+ */
+ private boolean syncEnabled = false;
+ /**
+ * 履约规则金额类型默认值(fdFundType)
+ */
+ private String fundType = "byAmount";
+ }
+
+ @Data
+ public static class Risk {
+ /**
+ * 合同推送前风险校验开关
+ */
+ private boolean checkOnContract = true;
+ /**
+ * 付款提交前风险校验开关
+ */
+ private boolean checkOnPayment = true;
+ }
+
+ @Data
+ public static class Credit {
+ /**
+ * 额度校验开关(C106-1,一期暂不对接,默认关;关闭后特批无自动触发)
+ */
+ private boolean checkEnabled = false;
+ /**
+ * 付款提交校验是否要求合同已推送合规(hcs_fd_id 存在)
+ */
+ private boolean requireContractSynced = true;
+ }
+
+ @Data
+ public static class SpecPay {
+ /**
+ * 特批单据状态(fd_doc_status,示例 10,§9-U3 待联调)
+ */
+ private String docStatus = "10";
+ /**
+ * 付款用途字典值(fd_payment_purpose,§9-U3 待联调后配置)
+ */
+ private String paymentPurpose = "";
+ /**
+ * 付款性质字典值(fd_payment_nature,§9-U3 待联调后配置)
+ */
+ private String paymentNature = "";
+ /**
+ * 付款类型字典值(fd_payment_type,§9-U3 待联调后配置)
+ */
+ private String paymentType = "";
+ /**
+ * 付款特批流程模板ID(fdTemplateId,每个环境/公司不同,按环境在 Nacos 维护)
+ */
+ private String templateId = "";
+ }
+
+ @Data
+ public static class Invoice {
+ /**
+ * TMS 发票类型 -> HCS 发票类型字典值(§9-U3 待联调后配置)
+ */
+ private Map invoiceTypeMapping = new HashMap<>();
+ /**
+ * TMS 币种码 -> HCS 币种 fdId(经 C103-12 币种表缓存取值,§9-U3 待联调后配置)
+ */
+ private Map currencyIdMapping = new HashMap<>();
+ /**
+ * 开票登记款项性质默认值(fdFundNature)
+ */
+ private String fundNatureDefault = "progress";
+ }
+
+ @Data
+ public static class ReceiptBill {
+ /**
+ * TMS 发票类型 -> HCS 收票发票类型字典值(§9-U3 待联调后配置)
+ */
+ private Map invoiceTypeMapping = new HashMap<>();
+ }
+
+ @Data
+ public static class PerfRecord {
+ /**
+ * 履约记录同步开关(C108-1/2,一期暂不对接,默认关)
+ */
+ private boolean syncEnabled = false;
+ /**
+ * 履约记录表单模板ID(fd_xform_id,环境相关值,§9-U9 待合规方提供)
+ */
+ private String xformId = "";
+ }
+
+ @Data
+ public static class Attachment {
+ /**
+ * 附件转传总开关
+ */
+ private boolean transferEnabled = true;
+ /**
+ * TMS 文件服务下载基址(拉取附件流用)
+ */
+ private String fileDownloadBase = "";
+ }
+
+ @Data
+ public static class MasterData {
+ /**
+ * 模板/分类/币种列表缓存时长(分钟),启动/首次使用时拉取并缓存(设计文档 §5.9)
+ */
+ private int ttlMinutes = 120;
+ }
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/ThirdPartyHcsAutoConfiguration.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/ThirdPartyHcsAutoConfiguration.java
new file mode 100644
index 00000000..1331c36a
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/ThirdPartyHcsAutoConfiguration.java
@@ -0,0 +1,41 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 第三方API 合同合规系统(HCS)配置类
+ *
+ * @author Chill
+ */
+@ComponentScan("org.springblade.hcs")
+@EnableConfigurationProperties(HcsProperties.class)
+@Configuration
+public class ThirdPartyHcsAutoConfiguration {
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/constant/HcsConstant.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/constant/HcsConstant.java
new file mode 100644
index 00000000..83ca8ddb
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/constant/HcsConstant.java
@@ -0,0 +1,63 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.constant;
+
+/**
+ * 合同合规系统(HCS)常量类
+ *
+ * @author Chill
+ */
+public class HcsConstant {
+
+ /**
+ * 接口调用成功 messageKey(统一出参 code,业务成败以 success 字段为准)
+ */
+ public static final String SUCCESS_CODE = "return.optSuccess";
+
+ /**
+ * 网关认证请求头名称(整值可配,如 Basic d2Njazp...)
+ */
+ public static final String HEADER_AUTHORIZATION = "Authorization";
+
+ /**
+ * 网关 Key-Auth 认证请求头名称(v4 接口说明:所有接口固定携带)
+ */
+ public static final String HEADER_KEY_AUTH = "Key-Auth";
+
+ /**
+ * signer 相对方单位类型:甲方=inner(内部主体)
+ */
+ public static final String SIGNER_FD_TYPE_INNER = "inner";
+
+ /**
+ * signer 相对方单位类型:乙方=supplier(外部相对方)
+ */
+ public static final String SIGNER_FD_TYPE_SUPPLIER = "supplier";
+ /**
+ * signer 相对方单位类型:客户=customer
+ */
+ public static final String SIGNER_FD_TYPE_CUSTOMER = "customer";
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentDownloadDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentDownloadDTO.java
new file mode 100644
index 00000000..95c56a68
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentDownloadDTO.java
@@ -0,0 +1,50 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)C103-5 附件下载入参(对账/排查备用)
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsAttachmentDownloadDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 附件ID
+ */
+ private String fdId;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentRefDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentRefDTO.java
new file mode 100644
index 00000000..65a5d05e
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentRefDTO.java
@@ -0,0 +1,70 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)附件引用项(附件先经 C103-2 上传取得 fdId,再经 mechanisms.attachment[] 引用)
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsAttachmentRefDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 附件ID(附件上传接口返回)
+ */
+ private String fdId;
+ /**
+ * 附件名称(不含扩展名)
+ */
+ private String fdFileName;
+ /**
+ * 附件扩展名
+ */
+ private String fdFileExtName;
+ /**
+ * 附件完整文件名
+ */
+ private String fullName;
+ /**
+ * 附件绑定的表单字段 key:特批 fd_spc_approval_attach、履约记录 fd_plan_att、收票单 fd_attach
+ */
+ private String fdEntityKey;
+ /**
+ * 绑定类型:仅发起类接口传 ADD,更新类接口不传
+ */
+ private String fdBindType;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentVO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentVO.java
new file mode 100644
index 00000000..6b8db430
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsAttachmentVO.java
@@ -0,0 +1,50 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)C103-2 附件上传出参
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsAttachmentVO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 附件ID(调用方必须持久化/缓存映射,供各业务接口引用)
+ */
+ private String fdId;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsBpmOperationDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsBpmOperationDTO.java
new file mode 100644
index 00000000..50512898
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsBpmOperationDTO.java
@@ -0,0 +1,54 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)C102-1 项目立项新建 流程操作入参
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsBpmOperationDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 操作类型:固定 drafter_submit(以起草人身份直接提交流程)
+ */
+ private String operationType;
+ /**
+ * 立项表单模板编码(环境相关值,配置 thirdParty.hcs.project.template-code,§9-U9)
+ */
+ private String templateCode;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCategoryVO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCategoryVO.java
new file mode 100644
index 00000000..e497b40c
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCategoryVO.java
@@ -0,0 +1,78 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)C103-7 合同分类列表出参行(fdParent 可组装分类树)
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsCategoryVO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 分类ID
+ */
+ private String fdId;
+ /**
+ * 分类名称(查询条件 fdName.$contains)
+ */
+ private String fdName;
+ /**
+ * 上级分类
+ */
+ private HcsRefVO fdParent;
+ /**
+ * 状态:enable=启用 / disable=停用
+ */
+ private String fdStatus;
+ /**
+ * 层级ID:由各级分类 fdId 以 x 包裹拼接(x+上级fdId+x+fdId+x,v4 新增)
+ */
+ private String fdHierarchyId;
+ /**
+ * 创建人
+ */
+ private HcsRefVO fdCreator;
+ /**
+ * 创建人部门
+ */
+ private HcsRefVO fdCreatorDept;
+ /**
+ * 创建时间(毫秒时间戳)
+ */
+ private Long fdCreateTime;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java
new file mode 100644
index 00000000..8185a17c
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsClaimRecordAddDTO.java
@@ -0,0 +1,102 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 合同合规系统(HCS)C107-1 新增认领记录入参(将收款流水认领到合同/履约项)
+ *
+ * 校验:fdCollectionFlow.fdId、fdContractNumber(须在合规本地存在)、fdClaimAmount 必填,
+ * 认领金额必须大于 0 且不能超过该流水收款金额;fdExePlanStageId 可选,若传须属于该合同。
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsClaimRecordAddDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 收款流水(fdId 必填,为合规系统流水ID)
+ */
+ private HcsRefVO fdCollectionFlow;
+ /**
+ * 认领合同 {fdId, fdName}(fdId 为合规合同ID,随编号一并回溯)
+ */
+ private HcsRefVO fdContract;
+ /**
+ * 认领合同编号(必填,须在合规系统本地存在)
+ */
+ private String fdContractNumber;
+ /**
+ * 对应履约项ID(可选,须属于该合同)
+ */
+ private String fdExePlanStageId;
+ /**
+ * 认领款项类型(prepayment=预付款,progress=进度款,acceptance=验收款,retention=质保金)
+ */
+ private String fdFundNature;
+ /**
+ * 认领人 {fdId, fdName}
+ */
+ private HcsRefVO fdClaimer;
+ /**
+ * 认领人名称
+ */
+ private String fdClaimerName;
+ /**
+ * 认领人部门 {fdId, fdName}
+ */
+ private HcsRefVO fdClaimerDept;
+ /**
+ * 认领人部门名称
+ */
+ private String fdClaimerDeptName;
+ /**
+ * 认领金额(必填,必须大于0,且不能超过收款金额)
+ */
+ private BigDecimal fdClaimAmount;
+ /**
+ * 认领日期(毫秒时间戳)
+ */
+ private Long fdClaimDate;
+ /**
+ * 备注
+ */
+ private String fdRemark;
+ /**
+ * 状态:0=已认领(默认),1=已作废
+ */
+ private Integer fdStatus;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCollectionFlowVO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCollectionFlowVO.java
new file mode 100644
index 00000000..ebc3af2a
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsCollectionFlowVO.java
@@ -0,0 +1,74 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for their own commercial purposes.
+ *
+ * Using this software signifies agreement to this license agreement and terms of use,
+ * and you expressly agree to be bound by the terms of use of this software.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
+ * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 合同合规系统(HCS)C104-2 收款流水分页行(认领记录推送前按明细流水号定位合规侧收款流水ID)
+ *
+ * 合规侧收款流水由合规自 ERP 侧同步(fdDataSource 默认 erp),TMS 以明细流水号(fdDetailSerialNumber)
+ * 精确匹配取 fdId,供 C107-1 认领记录的 fdCollectionFlow.fdId 引用。
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsCollectionFlowVO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 合规收款流水ID(C107-1 fdCollectionFlow.fdId 引用)
+ */
+ private String fdId;
+ /**
+ * 认领公告
+ */
+ private String fdClaimNotice;
+ /**
+ * 明细流水号(与 TMS blade_kingdee_receipt_flow.detail_serial_no 同源)
+ */
+ private String fdDetailSerialNumber;
+ /**
+ * 收款金额
+ */
+ private BigDecimal fdCollectionAmount;
+ /**
+ * 认领状态:0=未认领,1=部分认领,2=认领完成
+ */
+ private Integer fdClaimStatus;
+ /**
+ * 已认领金额
+ */
+ private BigDecimal fdClaimedAmount;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailQueryDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailQueryDTO.java
new file mode 100644
index 00000000..6075cf10
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailQueryDTO.java
@@ -0,0 +1,56 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 合同合规系统(HCS)C103-4 合同详情入参(fdId 与 fdNumber 二选一)
+ *
+ * TODO 待联调确认:出参 data 结构 v4 未定义,对账功能联调后以实际返回为准(设计文档 §5.9)。
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsContractDetailQueryDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 合同ID
+ */
+ private String fdId;
+ /**
+ * 合同编号
+ */
+ private String fdNumber;
+}
diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailVO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailVO.java
new file mode 100644
index 00000000..93c87dff
--- /dev/null
+++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsContractDetailVO.java
@@ -0,0 +1,519 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.hcs.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 合同合规系统(HCS)C103-4 合同详情出参(v4 字段表)
+ *
+ * 级联/集合类字段(对象/数组)内部结构 v4 标注以实际返回为准,暂以 Object/List<Object> 承接,
+ * 联调确认后细化。fdSepcailContract 为接口文档原文拼写(Special 笔误,必须沿用)。
+ *
+ * @author Chill
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HcsContractDetailVO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ /**
+ * 合同ID
+ */
+ private String fdId;
+ /**
+ * 租户ID
+ */
+ private String fdTenantId;
+ /**
+ * 可阅读标示
+ */
+ private String fdReaderFlag;
+ /**
+ * 不可阅读标记(可阅读者在文档发布前生效)
+ */
+ private String fdNotReaderFlag;
+ /**
+ * 可阅读者(在文档发布前生效)
+ */
+ private String fdRBPFlag;
+ /**
+ * 修改人
+ */
+ private String fdAlter;
+ /**
+ * 修改时间(毫秒时间戳)
+ */
+ private Long fdAlterTime;
+ /**
+ * 描述
+ */
+ private String fdDesc;
+ /**
+ * 是否隐藏
+ */
+ private Boolean fdIsHidden;
+ /**
+ * 最后修改时间(毫秒时间戳)
+ */
+ private Long fdLastModifiedTime;
+ /**
+ * 合同名称
+ */
+ private String fdSubject;
+ /**
+ * 合同分类(层级ID)
+ */
+ private String fdCateHierarchyId;
+ /**
+ * 编号
+ */
+ private String fdNumber;
+ /**
+ * 状态:draft=草稿; approving=审批中; approved=已审批; tosign=待签订; signing=签订中; signerror=签订异常; signed=已签订等
+ */
+ private String fdStatus;
+ /**
+ * 合同发起ID
+ */
+ private String fdDraftMainId;
+ /**
+ * 合同模板ID
+ */
+ private String fdTemplateId;
+ /**
+ * 审批状态:approving=审批中; approved=已审批; approvalinvalid=审批作废
+ */
+ private String fdApprovalStatus;
+ /**
+ * 签订状态:tosign=待签订; signing=签订中; signerror=签订异常; signed=已签订; signinvalid=签订作废
+ */
+ private String fdSignStatus;
+ /**
+ * 签订异常信息
+ */
+ private String fdSignErrorMsg;
+ /**
+ * 进入待签订时间(毫秒时间戳)
+ */
+ private Long fdToSignTime;
+ /**
+ * 签订发起时间(毫秒时间戳)
+ */
+ private Long fdSignCreateTime;
+ /**
+ * 完成签订时间(毫秒时间戳)
+ */
+ private Long fdSignedTime;
+ /**
+ * 归档状态:toarchive=待归档; archiving=归档中; archived=已归档; archiveerror=归档异常
+ */
+ private String fdArchiveStatus;
+ /**
+ * 变更状态:changing=变更中; changed=已变更; terminated=已终止
+ */
+ private String fdChangeStatus;
+ /**
+ * 履约状态:toplan=待履约; planing=履约中; planerror=履约异常; planed=已履约; planterminate=履约终止
+ */
+ private String fdPlanStatus;
+ /**
+ * 进入待履约时间(毫秒时间戳)
+ */
+ private Long fdToPlanTime;
+ /**
+ * 完成履约时间(毫秒时间戳)
+ */
+ private Long fdPlanedTime;
+ /**
+ * 关闭状态:toclose=待关闭; closing=关闭中; closed=已关闭
+ */
+ private String fdCloseStatus;
+ /**
+ * 进入待关闭时间(毫秒时间戳)
+ */
+ private Long fdToCloseTime;
+ /**
+ * 关闭时间(毫秒时间戳)
+ */
+ private Long fdClosedTime;
+ /**
+ * 签署提供者:psign=物理签署; esign=电子签署; qjsign=智能用印签署
+ */
+ private String fdSignProvider;
+ /**
+ * 签署实体ID
+ */
+ private String fdSignEntityId;
+ /**
+ * 归档方式:auto=一键申请; review=流程申请
+ */
+ private String fdFileWay;
+ /**
+ * 归档实体ID
+ */
+ private String fdArchiveEntityId;
+ /**
+ * 进入待归档时间(毫秒时间戳)
+ */
+ private Long fdToArchiveTime;
+ /**
+ * 完成归档时间(毫秒时间戳)
+ */
+ private Long fdArchivedTime;
+ /**
+ * 履约计划创建时机:draft=起草环节创建; toplan=履约环节创建
+ */
+ private String fdPlanCreateMoment;
+ /**
+ * 关闭方式:auto=一键关闭; review=流程关闭
+ */
+ private String fdCloseWay;
+ /**
+ * 关闭实体ID
+ */
+ private String fdCloseEntityId;
+ /**
+ * 是否锁定
+ */
+ private Boolean fdIsLocked;
+ /**
+ * 锁定原因:invalid=合同作废; change=合同变更; termination=合同终止
+ */
+ private String fdLockReason;
+ /**
+ * 合同作废实体ID
+ */
+ private String fdInvalidEntityId;
+ /**
+ * 上次合同变动发起实体ID
+ */
+ private String fdLastChangeEntityId;
+ /**
+ * 合同终止发起实体ID
+ */
+ private String fdTerminationEntityId;
+ /**
+ * 批量发起任务ID
+ */
+ private String fdBatchTaskId;
+ /**
+ * 是否关联交易
+ */
+ private Boolean fdIsRelaDeal;
+ /**
+ * 是否补录
+ */
+ private Boolean fdIsRecord;
+ /**
+ * 履约计划(级联字段,结构以实际返回为准)
+ */
+ private Object fdPlan;
+ /**
+ * 验收计划(级联字段,结构以实际返回为准)
+ */
+ private Object fdAcptPlan;
+ /**
+ * 外部合同数据(级联字段,结构以实际返回为准)
+ */
+ private Object fdOuterAttr;
+ /**
+ * 订单明细配置ID
+ */
+ private String fdOrderDetailConfigId;
+ /**
+ * 收付款进度(结构以实际返回为准)
+ */
+ private Object fdPayColProgress;
+ /**
+ * 开票进度(结构以实际返回为准)
+ */
+ private Object fdInvoiceProgress;
+ /**
+ * 履约状态(新):0=正常; 1=低风险; 2=中风险; 3=高风险
+ */
+ private Integer fdPlanStatusNew;
+ /**
+ * 是否冻结
+ */
+ private Boolean fdIsFreeze;
+ /**
+ * 解冻表单关联ID
+ */
+ private String fdUnfreezeFormId;
+ /**
+ * 合同履约流程状态:00/01/02/10/11/12/20/21/29/30/39
+ */
+ private String fdPlanFlowStatus;
+ /**
+ * 合同履约流程ID
+ */
+ private String fdPlanFlowId;
+ /**
+ * 是否评分
+ */
+ private Boolean fdRated;
+ /**
+ * 完成审批时间(毫秒时间戳)
+ */
+ private Long fdApprovedTime;
+ /**
+ * 是否同步金蝶
+ */
+ private Boolean fdIsSyncJd;
+ /**
+ * 同步金蝶状态:0/1/2
+ */
+ private Integer fdSyncJdStatus;
+ /**
+ * 特批合同(字段名原文拼写笔误,必须沿用)
+ */
+ private Boolean fdSepcailContract;
+ /**
+ * 合同结算模式:open=开口合同; close=闭口合同。开口合同指合同签订时不明确约定总金额、总数量、具体收付款日期,按实际发生的供货量、业务量结算的合同
+ */
+ private String fdContractGwNature;
+ /**
+ * 合同类型:standard=标准合同; halfstandard=半标准合同; notstandard=非标准合同
+ */
+ private String fdContractCharacter;
+ /**
+ * 合同性质:general=普通合同; framework=框架协议
+ */
+ private String fdContractNature;
+ /**
+ * 引用范本ID
+ */
+ private String fdModelId;
+ /**
+ * 合同属性:main=主合同; child=子合同; common=普通
+ */
+ private String fdContractAttribute;
+ /**
+ * 承办人
+ */
+ private HcsRefVO fdUnderTaker;
+ /**
+ * 承办人部门
+ */
+ private HcsRefVO fdUnderTakerDept;
+ /**
+ * 预计开始时间(毫秒时间戳)
+ */
+ private Long fdEstimateStartTime;
+ /**
+ * 预计结束时间(毫秒时间戳)
+ */
+ private Long fdEstimateEndTime;
+ /**
+ * 实际开始时间(毫秒时间戳)
+ */
+ private Long fdStartTime;
+ /**
+ * 实际结束时间(毫秒时间戳)
+ */
+ private Long fdEndTime;
+ /**
+ * 合同发起来源:inner=内部发起; outer=外部发起
+ */
+ private String fdContractSource;
+ /**
+ * 外部来源系统
+ */
+ private String fdOuterSystem;
+ /**
+ * 合同结算类型:payment=付款; payment_collection=收支双向; collection=收款; none=不结算
+ */
+ private String fdSettlementType;
+ /**
+ * 合同金额
+ */
+ private BigDecimal fdAmount;
+ /**
+ * 币种
+ */
+ private String fdCurrencyId;
+ /**
+ * 是否原始合同
+ */
+ private Boolean fdIsOrigin;
+ /**
+ * 原始合同ID
+ */
+ private String fdOriginContractId;
+ /**
+ * 合同变更类型:change=合同变更; termination=合同终止
+ */
+ private String fdChangeType;
+ /**
+ * 是否含税
+ */
+ private Boolean fdIncludeTax;
+ /**
+ * 是否引用范本
+ */
+ private Boolean fdUseModel;
+ /**
+ * 引用范本名称
+ */
+ private String fdModelName;
+ /**
+ * 引用范本版本
+ */
+ private String fdModelVersion;
+ /**
+ * 是否固定期限
+ */
+ private Boolean fdIsFixedTerm;
+ /**
+ * 合同开始期限
+ */
+ private String fdStartTerm;
+ /**
+ * 合同结束期限
+ */
+ private String fdEndTerm;
+ /**
+ * 有效期限
+ */
+ private String fdValidTerm;
+ /**
+ * 项目立项ID
+ */
+ private String fdProjectId;
+ /**
+ * 合同份数
+ */
+ private Integer fdServings;
+ /**
+ * 合同说明
+ */
+ private String fdContractDescription;
+ /**
+ * 合同的变更流程状态:changing=变更中; changed=已变更; noChangeProcess=无变更流程
+ */
+ private String fdChangeLbpmStatus;
+ /**
+ * 紧急程度:1=普通; 2=急件; 3=特急
+ */
+ private Integer fdUrgencyLevel;
+ /**
+ * 创建人
+ */
+ private HcsRefVO fdCreator;
+ /**
+ * 创建时间(毫秒时间戳)
+ */
+ private Long fdCreateTime;
+ /**
+ * 创建者部门
+ */
+ private HcsRefVO fdCreatorDept;
+ /**
+ * 权限(级联字段,结构以实际返回为准)
+ */
+ private Object fdPermissions;
+ /**
+ * 可阅读者(结构以实际返回为准)
+ */
+ private List fdReaders;
+ /**
+ * 所有可阅读者(结构以实际返回为准)
+ */
+ private List fdAllReaders;
+ /**
+ * 所有可阅读者ID(结构以实际返回为准)
+ */
+ private List fdAllReaderIds;
+ /**
+ * 可维护者(结构以实际返回为准)
+ */
+ private List fdEditors;
+ /**
+ * 所有可编辑者(结构以实际返回为准)
+ */
+ private List fdAllEditors;
+ /**
+ * 协办人(结构以实际返回为准)
+ */
+ private List fdCoTakers;
+ /**
+ * 合同时间线(结构以实际返回为准)
+ */
+ private List fdTimeLines;
+ /**
+ * 合同关联(结构以实际返回为准)
+ */
+ private List fdRelations;
+ /**
+ * 合同签约方及地位(结构以实际返回为准)
+ */
+ private List fdStandingSigners;
+ /**
+ * 签印文件(结构以实际返回为准)
+ */
+ private List fdSignFiles;
+ /**
+ * 业务明细(结构以实际返回为准)
+ */
+ private List fdBusinessDetails;
+ /**
+ * 付款记录(结构以实际返回为准)
+ */
+ private List fdPaymentRecords;
+ /**
+ * 收款记录(结构以实际返回为准)
+ */
+ private List fdCollectionRecords;
+ /**
+ * 收票记录(结构以实际返回为准)
+ */
+ private List fdInInvoices;
+ /**
+ * 开票记录(结构以实际返回为准)
+ */
+ private List