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
index 4b06fcf8..471f5004 100644
--- 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
@@ -70,7 +70,7 @@ import java.util.Objects;
*
* 扫描任务按序执行:① 客商风险校验(可配开关,黑名单阻断)→ ② 附件转传(挂载点待 §9-U8 确认,先转传缓存)→
* ③ C103-3 判重(§9-U4 查询能力联调确认)→ ④ C103-1 起草 / 变更单 C103-11 更新(§9-U5 定位方式待联调)→
- * ⑤ 履约规则 C103-9(P2 接线)。
+ * ⑤ 履约规则 C103-9 行级同步({@link HcsExeStageSyncSupport})。
* 结果留痕:风险校验写 blade_hcs_check_log 与合同 hcs_risk_* 字段;推送结果回填 hcs_fd_id/hcs_sync_status。
*
* @author Chill
@@ -97,6 +97,7 @@ public class HcsContractSyncSupport {
private final CustomerArchiveMapper customerArchiveMapper;
private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
private final HcsAttachmentSupport hcsAttachmentSupport;
+ private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
/**
* 推送合同到 HCS:风险校验 → 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced
@@ -112,20 +113,26 @@ public class HcsContractSyncSupport {
// ② 附件转传(先转传并缓存合规附件ID,C103-1 挂载方式待 §9-U8 确认后接线)
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");
- return "判重命中,回填已有合规合同ID";
+ resultMessage = "判重命中,回填已有合规合同ID";
+ } else {
+ String fdContractId = draftContract(contractManage);
+ // 先持久化合规合同ID(调用方必须保存),履约规则同步失败时下轮凭其走更新补偿
+ markSynced(contractManage, fdContractId, "C103-1 合同发起成功");
+ resultMessage = "C103-1 合同发起成功";
}
- String fdContractId = draftContract(contractManage);
- // TODO 待联调(P2):履约规则 C103-9 行级同步随合同链路执行(§5.3,依赖 blade_contract_exe_stage 表)
- markSynced(contractManage, fdContractId, "C103-1 合同发起成功");
- return "C103-1 合同发起成功";
+ } else {
+ updateContract(contractManage);
+ markSynced(contractManage, contractManage.getHcsFdId(), "C103-11 合同更新成功");
+ resultMessage = "C103-11 合同更新成功";
}
- updateContract(contractManage);
- markSynced(contractManage, contractManage.getHcsFdId(), "C103-11 合同更新成功");
- return "C103-11 合同更新成功";
+ // ⑤ 履约规则 C103-9 行级同步(随合同链路,§5.3;失败抛出由任务落 failed,下轮凭 hcs_fd_id 走更新补偿)
+ String exeStageResult = hcsExeStageSyncSupport.syncExeStages(contractManage);
+ return resultMessage + ";" + exeStageResult;
}
/**
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..301c385c
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsExeStageSyncSupport.java
@@ -0,0 +1,271 @@
+/**
+ * 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.system.cache.UserCache;
+import org.springblade.system.pojo.entity.User;
+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 ? "无响应" : result.getMsg(), contractManage.getContractNo());
+ throw new HcsException("C103-9 履约规则同步失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsExeplanVO exeplan = result.getData();
+ backfill(contractManage, pendingRows, param.getFdStages(), exeplan);
+ return String.format("C103-9 履约规则同步成功(%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());
+ // 【必填】操作人登录名:合同承办人登录名(§9-U2)
+ param.setFdLoginName(requireLoginName(contractManage));
+ // 【必填】计划整体操作类型:首次同步 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)
+ 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("C103-9 返回阶段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("C103-9 同步成功");
+ 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);
+ }
+ }
+
+ /**
+ * 合同承办人登录名(fdLoginName 必填)
+ */
+ private String requireLoginName(ContractManage contractManage) {
+ if (contractManage.getHandlerUserId() == null) {
+ throw new HcsException("C103-9 履约规则同步失败,合同承办人未维护(contractNo=" + contractManage.getContractNo() + ")");
+ }
+ User user = UserCache.getUser(contractManage.getHandlerUserId());
+ if (user == null || StringUtil.isBlank(user.getAccount())) {
+ throw new HcsException("C103-9 履约规则同步失败,承办人登录名解析不到(§9-U2)");
+ }
+ return user.getAccount();
+ }
+
+ /**
+ * 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/task/HcsPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java
index 896a3d21..6d35899c 100644
--- 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
@@ -51,7 +51,7 @@ import java.util.UUID;
*
* 审批代码零埋点:扫描只认 approvalStatus=approved/change_approved 且 hcs_sync_status=unsynced/failed(重试未超限),
* 与审批入口(/approve、MK 回调)无关;失败落 failed 由下轮扫描自动补偿,超限仅人工重推(D7)。
- * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 P2 接线)。
+ * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 C103-9 行级同步)。
* 多实例部署时以 Redis 分布式锁防重扫(照既有 BladeRedis 用法,单实例部署行为与既有金蝶任务一致)。
* 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts,供手动端点(HcsSyncController)复用。
*