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)复用。 *