feat: HCS履约规则C103-9行级同步(随合同链路执行/行级增删改聚合/履约计划ID与阶段fdId回填持久化)

This commit is contained in:
刘泉佳
2026-09-27 23:35:20 +08:00
parent 892035ee6f
commit d147ef8209
3 changed files with 288 additions and 10 deletions
@@ -70,7 +70,7 @@ import java.util.Objects;
* <p>
* 扫描任务按序执行:① 客商风险校验(可配开关,黑名单阻断)→ ② 附件转传(挂载点待 §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;
}
/**
@@ -0,0 +1,271 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support.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)
* <p>
* 随合同推送链路执行(合同须已同步、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<HcsContractExeStage> 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<HcsExeplanVO> 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<HcsContractExeStage> 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<HcsExeplanStageDTO> 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 并清除行级操作标记
* <p>
* TODO 待联调确认:fdChangeStages 与请求行的对应关系 v4 未明确,优先按请求行序对应(首次全 add 场景),
* 行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。
*/
private void backfill(ContractManage contractManage, List<HcsContractExeStage> pendingRows,
List<HcsExeplanStageDTO> requestStages, HcsExeplanVO exeplan) {
// 履约计划ID 回填(后续行更新/删除依赖,必须持久化)
if (StringUtil.isNotBlank(exeplan.getFdExeplanId())
&& !exeplan.getFdExeplanId().equals(contractManage.getHcsExeplanId())) {
contractManage.setHcsExeplanId(exeplan.getFdExeplanId());
}
backfillExeplanId(contractManage);
List<HcsExeplanStageVO> changeStages = exeplan.getFdChangeStages() == null ? List.of() : exeplan.getFdChangeStages();
// 行数一致时按请求行序一一对应;不一致时按业务属性组合键匹配
boolean positional = changeStages.size() == requestStages.size();
Map<String, HcsExeplanStageVO> 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<String, HcsExeplanStageVO> mapByAttribute(List<HcsExeplanStageVO> changeStages) {
Map<String, HcsExeplanStageVO> 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();
}
}
@@ -51,7 +51,7 @@ import java.util.UUID;
* <p>
* 审批代码零埋点:扫描只认 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)复用。
* <p>