From 8e6e263921ed81c52369c340d752bb5c1898d55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 11 Oct 2026 17:47:53 +0800 Subject: [PATCH] =?UTF-8?q?:feat:=20=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=AE=A2=E5=95=86=E9=AB=98=E9=A3=8E=E9=99=A9=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E7=AE=A1=E6=8E=A7=E4=B8=8E=E5=90=88=E5=90=8C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=89=B9=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. GET /hcs/risk 返回 CustomerRiskVO:内部组织客商默认无风险不调合规,外部客商高风险/黑名单返回固定风险提示文案(riskTip),供基本信息备注后展示 2. 合同三个提交入口(submit/toTemporary/submitFormal)校验:甲乙方任一方高风险/黑名单时必须上传特批说明附件(attachmentType=spec_explanation) 3. pushContract 流程重排:C101-1 风险校验仅留痕不再阻断,C103-1 起草成功后高风险/黑名单合同自动挂起特批审核(currentNode)并发起 C103-8,解开原 need 挂起无 hcs_fd_id 的特批死锁 4. 特批结果联动:B103 回传/人工确认 approved 恢复审批通过展示,rejected 联动合同审批驳回走重新审批闭环 5. 补充推送入口/自动特批触发/跳过原因日志,流程梳理文档同步更新(§二/§七/§八/新增§九) --- .../transport/pojo/vo/CustomerRiskVO.java | 66 ++++++ .../transport/constant/HcsSyncConstant.java | 27 +++ .../controller/HcsSyncController.java | 11 +- .../impl/ContractManageServiceImpl.java | 39 ++++ .../hcs/HcsContractSpecSyncSupport.java | 21 +- .../support/hcs/HcsContractSyncSupport.java | 142 ++++++++++-- .../support/hcs/HcsCreditCheckSupport.java | 58 ++++- .../hcs合同/2026-10-11-hcs合同推送流程梳理.md | 211 ++++++++++++++++++ 8 files changed, 536 insertions(+), 39 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java create mode 100644 doc/docs/hcs合同/2026-10-11-hcs合同推送流程梳理.md 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..1b523f3a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CustomerRiskVO.java @@ -0,0 +1,66 @@ +/** + * 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 = "客商类型: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 = "是否高风险/黑名单客商(外部客商专用,内部组织恒为 false)") + private Boolean risky; + + @Schema(description = "风险客商提示文案(基本信息备注后展示,安全客商为空)") + private String riskTip; + +} 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 index 5b9318a8..b546a004 100644 --- 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 @@ -148,6 +148,33 @@ public final class HcsSyncConstant { */ public static final String SPEC_STATUS_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"; + + /** + * 合同附件类型字段名(前端约定:附件元数据 attachmentType) + */ + public static final String ATTACHMENT_TYPE_FIELD = "attachmentType"; + + /** + * 附件类型:特批说明(高风险/黑名单客商合同必须上传) + */ + public static final String ATTACHMENT_TYPE_SPEC_EXPLANATION = "spec_explanation"; + + /** + * 特批审核挂起节点展示名(currentNode,特批发起后挂起展示,结果回传后恢复) + */ + public static final String SPEC_REVIEW_NODE = "特批审核"; + // ==================== Redis 缓存 key(照 BFI blade:third: 前缀惯例) ==================== /** 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 index 63ffd26a..63e1d77e 100644 --- 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 @@ -40,9 +40,9 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.StringUtil; import org.springblade.hcs.dto.HcsAttachmentRefDTO; import org.springblade.hcs.dto.HcsCurrencyVO; -import org.springblade.hcs.dto.HcsRiskVO; 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; @@ -176,13 +176,14 @@ public class HcsSyncController { } /** - * 客商风险等级查询(C101-1,展示用,前端接入设计 §3.3;强制校验仍在推送/付款链路) + * 客商风险等级查询(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.queryRiskByCustomerCode(customerCode)); + @Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕") + public R risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode) { + return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode)); } /** 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 cddc6b68..c3a51bb4 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 @@ -44,6 +44,7 @@ import org.springblade.transport.pojo.entity.HcsContractExeStage; import org.springblade.transport.pojo.vo.ContractManageVO; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.hcs.HcsCreditCheckSupport; import org.springblade.transport.support.hcs.HcsImmediatePushSupport; import org.springblade.transport.wrapper.ContractManageWrapper; import org.springframework.stereotype.Service; @@ -74,6 +75,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION + .equals(Objects.toString(attachment.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD), null))); + if (!hasSpecExplanation) { + throw new ServiceException("合同相对方存在高风险/黑名单客商,必须上传「特批说明」类型附件后再提交审批"); + } + } + + /** + * 单方客商是否高风险/黑名单(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/support/hcs/HcsContractSpecSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSpecSyncSupport.java index 6a1423c7..70bbad14 100644 --- 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 @@ -147,6 +147,8 @@ public class HcsContractSpecSyncSupport { 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); @@ -156,7 +158,7 @@ public class HcsContractSpecSyncSupport { /** * B103 特批结果回传处理(回调端点入口):候选字段解析 + 原文留痕, - * approved 时合同保持待同步(unsynced),由既有扫描任务自动恢复 C103-1 推送 + * approved 恢复合同"审批通过"展示(C103-1 已起草完成,无需恢复推送);rejected 联动合同审批驳回 * * @param body B103 回调报文(结构待联调,原文全量留痕) * @return 处理结果描述 @@ -179,6 +181,8 @@ public class HcsContractSpecSyncSupport { return "已留痕:结果字段未识别(contractNo=" + contractManage.getContractNo() + ")"; } String status = approved ? HcsSyncConstant.SPEC_STATUS_APPROVED : HcsSyncConstant.SPEC_STATUS_REJECTED; + // 特批结果联动合同审批状态(通过恢复展示,驳回联动审批驳回),随 markSpec 一并落库 + syncContractOnSpecResult(contractManage, approved); markSpec(contractManage, status, "B103 特批结果回传:" + raw); writeCallbackLog(contractManage, approved ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL, "B103 特批结果回传:" + raw); @@ -300,6 +304,21 @@ public class HcsContractSpecSyncSupport { return null; } + /** + * 特批结果联动合同审批状态(confirmSpecResult 与 handleSpecCallback 共用,仅改字段,随 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("待处理"); + } + } + /** * 回写特批状态与动作时间 */ 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 80667201..38bb48de 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 @@ -29,6 +29,7 @@ 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.dto.HcsAttachmentRefDTO; @@ -70,9 +71,10 @@ import java.util.Objects; /** * HCS 合同同步支撑组件(C103-1 发起 / C103-11 更新,设计文档 §5.2) *

- * 扫描任务按序执行:① 客商风险校验(可配开关,黑名单阻断)→ ② 附件转传(v4 挂载点 fdFieldJson.attachment,随 C103-1 挂载)→ - * ③ C103-3 判重(§9-U4 查询能力联调确认)→ ④ C103-1 起草 / 变更单 C103-11 更新(§9-U5 定位方式待联调)→ - * ⑤ 履约规则 C103-9 行级同步({@link HcsExeStageSyncSupport})。 + * 扫描任务按序执行:⓪ 特批挂起门禁 → ① 客商风险校验(可配开关,仅留痕,高风险不再阻断)→ + * ② 附件转传(v4 挂载点 fdFieldJson.attachment,随 C103-1 挂载)→ ③ C103-3 判重(§9-U4 查询能力联调确认)→ + * ④ C103-1 起草 / 变更单 C103-11 更新(§9-U5 定位方式待联调)→ ⑤ 高风险/黑名单自动特批 C103-8(外部客商管控, + * {@link HcsContractSpecSyncSupport},挂起"特批审核"待 B103 回传)→ ⑥ 履约规则 C103-9 行级同步({@link HcsExeStageSyncSupport})。 * 结果留痕:风险校验写 blade_hcs_check_log 与合同 hcs_risk_* 字段;推送结果回填 hcs_fd_id/hcs_sync_status。 * * @author Chill @@ -99,24 +101,28 @@ public class HcsContractSyncSupport { private final HcsContractCheckLogMapper hcsContractCheckLogMapper; private final HcsAttachmentSupport hcsAttachmentSupport; private final HcsExeStageSyncSupport hcsExeStageSyncSupport; + private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport; /** - * 推送合同到 HCS:特批挂起门禁 → 风险校验(黑名单挂起待特批)→ 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced + * 推送合同到 HCS:特批挂起门禁 → 风险校验(仅留痕,高风险不再阻断)→ 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced + * → 高风险/黑名单客商合同挂起"特批审核"并自动发起 C103-8 特批(外部客商管控) * * @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()); // ⓪ 特批挂起门禁:待特批/特批中的合同跳过推送,等待 B103 特批结果回传后恢复(C103-8,前端接入设计 §3.4) if (List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(contractManage.getHcsSpecStatus())) { log.info("HCS合同推送跳过(特批挂起中):contractNo={},hcsSpecStatus={}", contractManage.getContractNo(), contractManage.getHcsSpecStatus()); return "合同特批挂起中(" + contractManage.getHcsSpecStatus() + "),跳过推送"; } - // ① 客商风险校验(可配开关),黑名单且 block-blacklist=true 时挂起待特批(不再直接失败) + // ① 客商风险校验(可配开关):留痕 hcs_risk_* 字段并返回高风险/黑名单标志,不再阻断推送(高风险合同起草后自动转特批) + boolean risky = false; if (hcsProperties.getContract().isRequireRiskCheck()) { - if (checkRelativeRisk(contractManage)) { - return "高风险客商,合同已挂起待特批(C103-8)"; - } + risky = checkRelativeRisk(contractManage); } // ② 附件转传(v4 已明确挂载点:C103-1 fdFieldJson.attachment,转传取得合规附件ID 后随发起报文挂载) List attachmentFdIds = transferAttachmentsIfEnabled(contractManage); @@ -138,7 +144,12 @@ public class HcsContractSyncSupport { markSynced(contractManage, contractManage.getHcsFdId(), "C103-11 合同更新成功"); resultMessage = "C103-11 合同更新成功"; } - // ⑤ 履约规则 C103-9 行级同步(随合同链路,§5.3;一期暂不对接默认关,打开后失败抛出由任务落 failed,下轮凭 hcs_fd_id 走更新补偿) + // ⑤ 高风险/黑名单客商(外部客商管控):C103-1 起草成功后挂起"特批审核"并自动发起 C103-8; + // 特批已通过的合同不重复发起(单次有效),发起失败保持待特批可人工重发 + if (risky) { + autoApplySpecIfNeeded(contractManage); + } + // ⑥ 履约规则 C103-9 行级同步(随合同链路,§5.3;一期暂不对接默认关,打开后失败抛出由任务落 failed,下轮凭 hcs_fd_id 走更新补偿) if (!hcsProperties.getExeStage().isSyncEnabled()) { return resultMessage; } @@ -148,10 +159,10 @@ public class HcsContractSyncSupport { /** * C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段; - * 黑名单且 block-blacklist=true 时不再直接失败,而是置 hcs_spec_status=need 挂起待特批(C103-8); - * 特批已通过(approved,单次有效)的合同不重复挂起,保证 B103 回传后推送链路可走通 + * 返回是否高风险/黑名单(风险等级=2 或任一方黑名单),不再阻断推送—— + * 推送成功后由自动特批链路挂起处理(外部客商管控,见 {@link #autoApplySpecIfNeeded}) * - * @return true=黑名单挂起待特批(调用方跳过本次推送) + * @return true=高风险或黑名单客商(触发自动特批) */ private boolean checkRelativeRisk(ContractManage contractManage) { RiskResult riskPartyA = queryRisk(contractManage, "甲方", contractManage.getPartyA()); @@ -164,20 +175,103 @@ public class HcsContractSyncSupport { contractManage.setHcsBlackStatus(blacklist ? BLACK_STATUS_YES : "no"); contractManage.setHcsRiskTime(LocalDateTime.now()); contractManageMapper.updateById(contractManage); - log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}]", + 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()); - boolean specApproved = HcsSyncConstant.SPEC_STATUS_APPROVED.equals(contractManage.getHcsSpecStatus()); - if (blacklist && hcsProperties.getRisk().isBlockBlacklist() && !specApproved) { - String message = "高风险客商(甲方[" + riskPartyA.blackStatus() + "]乙方[" + riskPartyB.blackStatus() + "]),合同挂起待特批"; - contractManage.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_NEED); - contractManage.setHcsSpecTime(LocalDateTime.now()); - contractManage.setHcsSpecMsg(message); - contractManageMapper.updateById(contractManage); - log.warn("HCS合同推送因高风险客商挂起待特批:contractNo={},{}", contractManage.getContractNo(), message); - return true; + contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel(), risky); + return risky; + } + + /** + * 高风险/黑名单客商合同自动特批(C103-8,外部客商管控):C103-1 起草成功后置待特批并自动发起, + * TMS 流程挂起展示"特批审核"(currentNode),B103 特批结果回传后恢复或驳回; + * 特批已通过(单次有效)的合同不重复发起;发起失败保持待特批仅留痕,可人工重发或重新推送兜底 + */ + private void autoApplySpecIfNeeded(ContractManage contractManage) { + if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(contractManage.getHcsSpecStatus())) { + log.info("HCS合同高风险/黑名单但特批已通过(单次有效),跳过自动特批:contractNo={},hcsSpecStatus={}", + contractManage.getContractNo(), contractManage.getHcsSpecStatus()); + return; + } + contractManage.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_NEED); + contractManage.setHcsSpecTime(LocalDateTime.now()); + contractManage.setHcsSpecMsg("高风险/黑名单客商,合同推送合规成功后自动发起特批(C103-8)"); + // TMS 流程挂起为"特批审核",特批通过恢复展示、驳回联动合同审批驳回 + contractManage.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE); + contractManageMapper.updateById(contractManage); + log.info("HCS合同高风险/黑名单触发自动特批(C103-8):contractNo={},riskLevel={},blackStatus={},挂起节点={}", + contractManage.getContractNo(), contractManage.getHcsRiskLevel(), contractManage.getHcsBlackStatus(), + contractManage.getCurrentNode()); + try { + List attachments = transferSpecExplanationAttachments(contractManage); + hcsContractSpecSyncSupport.applySpec(contractManage.getId(), attachments, requireLoginName(contractManage)); + } catch (Exception exception) { + log.warn("HCS合同自动特批发起失败(保持待特批,可人工重发):contractNo={},{}", + contractManage.getContractNo(), exception.getMessage(), exception); + } + } + + /** + * 筛选合同「特批说明」类型附件(attachmentType=spec_explanation)并转传合规(C103-2), + * 返回特批附件引用;无特批说明附件返回空列表(提交时已强制校验,此处由 C103-8 防御性拦截) + */ + private List transferSpecExplanationAttachments(ContractManage contractManage) { + List> specAttachments = new ArrayList<>(); + for (Object item : parseJsonArray(contractManage.getAttachmentsJson())) { + if (item instanceof Map attachment + && HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION.equals(attachment.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD))) { + specAttachments.add(attachment); + } + } + if (specAttachments.isEmpty()) { + return List.of(); + } + String specJson = JsonUtil.toJson(specAttachments); + String loginName = requireLoginName(contractManage); + List attachmentFdIds = hcsAttachmentSupport.transferAttachments(specJson, loginName); + List attachments = new ArrayList<>(); + for (int index = 0; index < specAttachments.size(); index++) { + HcsAttachmentRefDTO ref = new HcsAttachmentRefDTO(); + // 附件ID:C103-2 转传返回的合规附件ID(与元数据行序一致) + ref.setFdId(attachmentFdIds.get(index)); + ref.setFdFileName(resolveAttachmentName(specAttachments.get(index))); + attachments.add(ref); + } + return attachments; + } + + /** + * 附件元数据文件名(name/originalName/fileName 依次取首个非空,照 HcsAttachmentSupport 惯例) + */ + private String resolveAttachmentName(Map attachment) { + for (String field : List.of("name", "originalName", "fileName")) { + Object value = attachment.get(field); + if (value != null && !String.valueOf(value).isBlank()) { + return String.valueOf(value); + } + } + return null; + } + + /** + * 解析 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(); } - return false; } /** 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 index eb72f01f..c056cbce 100644 --- 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 @@ -46,6 +46,7 @@ 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; @@ -171,16 +172,30 @@ public class HcsCreditCheckSupport { } /** - * 前端客商风险查询(C101-1,展示用,前端接入设计 §3.3): - * 按客商编号查询风险等级与黑名单状态并留痕(biz_id 为空);仅用于展示提示,不作为安全边界 + * 客商风险查询(C101-1,展示与提交校验共用,前端接入设计 §3.3): + * 内部组织类型客商默认无风险(不调 C101-1);外部客商按编号查询风险等级与黑名单状态并留痕; + * 高风险/黑名单时组装风险提示文案(风险项明细 C101-1 未返回,固定文案展示) * * @param customerCode 客商编号 - * @return 风险信息(fdBlackStatus/fdRiskLevel) + * @return 风险展示信息(customerKind/fdBlackStatus/fdRiskLevel/risky/riskTip) */ - public HcsRiskVO queryRiskByCustomerCode(String customerCode) { + 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.setCustomerName(customerArchive == null ? customerCode : customerArchive.getFullName()); + // 内部组织类型客商默认无风险;档案不存在按外部客商查询(不静默放行) + boolean internal = customerArchive != null && HcsSyncConstant.CUSTOMER_KIND_INTERNAL.equals(customerArchive.getCustomerKind()); + 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); @@ -192,20 +207,45 @@ public class HcsCreditCheckSupport { } HcsRiskVO risk = result.getData(); String blackStatus = risk == null ? null : risk.getFdBlackStatus(); - boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus); + 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.setRisky(risky); + if (risky) { + riskVO.setRiskTip("【" + riskVO.getCustomerName() + "】:被列入外部黑名单;涉贪污受贿裁判相关提及方(3年以上及其他);被执行人信息;买卖合同纠纷;劳动纠纷;重大纠纷"); + } try { HcsContractCheckLog checkLog = new HcsContractCheckLog(); checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT); checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK); - checkLog.setResult(blacklist ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS); - checkLog.setMsg("页面风险查询[" + customerCode + "]黑名单=" + blackStatus + ",风险等级=" + (risk == null ? null : risk.getFdRiskLevel())); + 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); + log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception); } - return risk; + 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); } /** diff --git a/doc/docs/hcs合同/2026-10-11-hcs合同推送流程梳理.md b/doc/docs/hcs合同/2026-10-11-hcs合同推送流程梳理.md new file mode 100644 index 00000000..c7cc11db --- /dev/null +++ b/doc/docs/hcs合同/2026-10-11-hcs合同推送流程梳理.md @@ -0,0 +1,211 @@ +# 新建合同推送 HCS(合同合规系统)流程梳理 + +> 依据代码现状整理,核心类:`HcsContractSyncSupport`(blade-service/blade-transport/.../support/hcs)。 +> 对接接口:C101-1 风险校验、C103-2 附件上传、C103-3 合同判重、C103-1 合同发起、C103-11 合同更新、C103-8 特批、C103-9 履约规则。 +> "必填" 标注来源:代码注释中标注的 v4 接口文档要求(HCS 侧),以及 TMS 推送前强校验(缺失直接失败)。 + +--- + +## 一、触发入口(共 3 个) + +| 入口 | 触发时机 | 代码位置 | 说明 | +| --- | --- | --- | --- | +| 审批通过即时推送 | 合同审批通过(含变更通过)事务提交后 | `ContractManageServiceImpl#approve` → 置 `hcsSyncStatus=unsynced` → `HcsImmediatePushSupport#pushContractAfterCommit` | 受开关 `thirdParty.hcs.push.immediate-enabled` 控制(默认关闭,走扫描兜底);afterCommit 异步执行(2 线程 daemon 池),Redis 单条锁防与扫描任务并发双推 | +| 定时扫描兜底 | 扫描周期到 | `HcsPushTask#pushUnsyncedContracts` | 扫描条件:`approvalStatus in (approved, change_approved)` 且 `hcsSyncStatus in (unsynced, failed)` 且 `hcsRetryCount < maxRetry`,按 `batchSize` 限量 | +| 页面手动重推 | 用户点"推送合规/重推"按钮 | `HcsSyncController` → `HcsContractSyncSupport#repushContract(id)` | 前置校验:仅审批通过可推、特批挂起中不可推;重置为待同步并清零重试计数后立即推送 | + +三条入口最终都收敛到 `HcsContractSyncSupport#pushContract(ContractManage)`。 + +--- + +## 二、推送主流程(pushContract 内部步骤) + +``` +⓪ 特批挂起门禁 + hcsSpecStatus ∈ {need, processing} → 跳过推送,等待 C103-8 特批结果回传 +① 客商风险校验(C101-1,开关 require-risk-check) + 甲方、乙方各查一次;按名称反查 CustomerArchive 客商编码,反查不到记 skip 留痕不阻断 + 仅留痕 hcs_risk_* 并返回高风险/黑名单标志(风险等级=2 或任一方黑名单),不再阻断推送 + (2026-10-11 变更:原"黑名单挂起终止推送"逻辑取消,改为起草成功后自动转特批,见 §九) +② 附件转传(C103-2,开关 attachment.transfer-enabled) + 合同主文件(approved=true)+ 其它附件上传合规,取得合规附件ID列表 +③ 判重(C103-3,仅首推 hcsFdId 为空时) + conditions = {"fdNumber": 合同编号};命中 → 回填已有合规合同ID,置 synced,结束 +④ 合同发起/更新 + 首推且未判重命中 → C103-1 发起(返回 fdContractId,必须持久化) + 已有 hcsFdId(变更单)→ C103-11 更新(顶层 fdId 定位) +⑤ 自动特批(C103-8,高风险/黑名单 且 hcsSpecStatus ≠ approved 时) + 置 hcsSpecStatus=need + currentNode="特批审核"(TMS 流程挂起展示) + → 筛选「特批说明」附件(attachmentsJson 中 attachmentType=spec_explanation)转传 C103-2 + → 自动发起 C103-8 → hcsSpecStatus=processing + 发起失败保持 need 仅留痕,可人工经 /hcs/contract/spec-apply 重发 +⑥ 履约规则同步(C103-9,开关 exe-stage.sync-enabled,一期默认关闭) + 随合同链路行级同步,失败抛出走下轮凭 hcs_fd_id 更新补偿 +``` + +结果回填: + +- **成功** `markSynced`:`hcs_fd_id` 回填、`hcs_sync_status=synced`、重试清零、记录 `hcs_sync_time/hcs_sync_msg` +- **失败** `markFailed`:`hcs_sync_status=failed`、重试次数 +1、记录失败原因,交扫描任务下轮补偿 + +--- + +## 三、C103-1 / C103-11 报文顶层字段(`HcsContractDraftDTO`) + +| HCS 字段 | 说明 | TMS 取值 | HCS 必填 | +| --- | --- | --- | --- | +| `fdId` | 合同ID | `contractManage.hcsFdId`(C103-1 返回值) | **必填**(C103-11 更新时);C103-1 新建不传 | +| `fdTemplateId` | 合同模板ID | `template-mapping[contractCategory]` 配置映射 | **必填**;TMS 强校验:未配置映射直接抛异常失败(§9-U7) | +| `fdOuterSystem` | 外部系统标识 | 配置 `thirdParty.hcs.src-system`(约定值 WLTMS) | **必填** | +| `fdLoginName` | 操作人登录名 | 合同承办人:`handlerUserId` → `UserCache.getUser().getAccount()` | **必填**;TMS 强校验:承办人未维护或登录名解析不到直接失败 | +| `saveType` | 保存类型 | 固定 `submit`(提交) | **必填** | +| `fdFieldJson` | 合同字段 | xformFields + signer + attachment(见下) | **必填** | + +--- + +## 四、fdFieldJson.xformFields 字段映射(`buildXformFields`) + +值一律字符串传值;时间字段格式 `yyyy-MM-dd` 且带 `fdType=timestamp`。 + +| HCS 字段(name) | 中文名 | TMS 来源字段 | HCS 必填 | 备注 | +| --- | --- | --- | --- | --- | +| `fdId` | 合同ID | `hcsFdId` | **必填**(更新时) | 新建不传 | +| `fdNumber` | 合同编号 | `contractNo` | **必填** | fd 模板字段;同时用作 C103-3 判重条件 | +| `CONTRACT_NO` | 合同编号 | `contractNo` | — | CONTRACT 模板字段(与 fdNumber 同值双传) | +| `fdSubject` | 合同标题 | `contractName` | **必填** | fd 模板字段 | +| `CONTRACT_NAME` | 合同名称 | `contractName` | — | CONTRACT 模板字段 | +| `fdAmount` | 合同金额 | `contractAmount` | **必填** | 字符串传值(`toPlainString`) | +| `AMOUNT` | 合同金额 | `contractAmount` | — | CONTRACT 模板字段 | +| `fdCurrencyId` | 币种 | `settlementCurrency` 经 `currency-name-mapping` | — | 默认 RMB→人民币;映射未命中传原值并告警 | +| `CURRENCY` | 币种 | 同上 | — | CONTRACT 模板字段 | +| `fdSettlementType` | 合同结算类型 | `settlement-type-mapping[settlementMode]` | **必填** | 枚举:payment=付款 / payment_collection=收支双向 / collection=收款 / none=不结算;未配置映射则不传并告警(§9-U3 待联调) | +| `fdSepcailContract` | 特批流程 | 固定 `false` | — | 文档原文拼写笔误必须沿用(D15);开关 `contract.special-contract-enabled` 预留 | +| `CONTRACT_TYPE` | 合同类型 | `contractCategory` | — | | +| `PARTY_A` | 甲方名称 | `partyA` | — | TMS 存名称字符串 | +| `PARTY_B` | 乙方名称 | `partyB` | — | | +| `SIGN_DATE` | 签订日期 | `signDate` | **必填** | yyyy-MM-dd,fdType=timestamp | +| `fdEstimateStartTime` | 预计开始时间 | `startDate` | 期限为指定日期时必填 | fdType=timestamp | +| `fdEstimateEndTime` | 预计结束时间 | `endDate` | 期限为指定日期时必填 | fdType=timestamp | +| `fdStartTerm` | 合同开始期限 | 固定 `specifiedDate`(指定日期) | **必填** | 日期取 `startDate`;startDate 为空则传空 | +| `fdEndTerm` | 合同结束期限 | 固定 `specifiedDate`(指定日期) | **必填** | 日期取 `endDate`;endDate 为空则传空 | +| `CREATE_TIME` | 创建时间 | `createTime` | **必填** | yyyy-MM-dd,fdType=timestamp;CONTRACT 模板字段 | +| `fdCreateTime` | 创建时间 | `createTime` | **必填** | fd 模板字段(与 CREATE_TIME 同值双传) | +| `fd_syn_time` | 同步时间 | 推送时刻(`LocalDateTime.now()`) | **必填** | yyyy-MM-dd,fdType=timestamp | +| `CREATE_USER_NAME` | 承办人名称 | `handlerUserName` | **必填** | | +| `CONTRACT_STATUS` | 合同状态 | `status-mapping[approvalStatus]` | **必填**(v4) | draft/approving/approved 等;未配置映射则不传并告警(§9-U3) | +| `fdServings` | 合同份数 | `copyCount` | **必填** | 字符串传值 | +| `fdDesc` | 合同说明 | `remark` | 选填 | | + +**v4 选填字段暂不传(待联调)**:`fdCoTakers` 协办人、`fdUnderTaker`/`fdUnderTakerDept` 承办人及部门(组织架构对象传值待确认)、`fdRelations` 关联合同、`fdProject` 关联项目(可经立项 hcs_fd_id 关联,对象传值待确认)、`fdContractGwNature` 结算模式、`fdContractAttribute` 合同属性、`fdUrgencyLevel` 紧急程度。 + +--- + +## 五、fdFieldJson.signer 签署方(相对方) + +固定传两条:甲方(`fdStanding=甲`)、乙方(`fdStanding=乙`)。 + +| HCS 字段 | 说明 | TMS 取值 | HCS 必填 | 备注 | +| --- | --- | --- | --- | --- | +| `fdStanding` | 签署地位 | 固定 甲 / 乙 | — | | +| `fdName` | 相对方名称 | `partyA` / `partyB` | — | | +| `fdNumber` | 相对方编号 | 按名称反查 `CustomerArchive.customerCode`(全称或简称精确匹配) | — | 反查不到留空不阻断(§9-U6) | +| `fdType` | 单位类型(inner=内部单位) | 暂不传 | — | inner/外部判定规则待联调(§9-U6) | + +--- + +## 六、fdFieldJson.attachment 附件(C103-2 转传后挂载) + +| HCS 字段 | 说明 | TMS 取值 | HCS 必填 | 备注 | +| --- | --- | --- | --- | --- | +| 列表本体 | 附件数据 | C103-2 转传返回的合规附件ID列表 | **必填**(C103-1 时,无附件传空列表) | C103-11 更新传 null 不重复挂载 | +| `fdId` | 附件ID | C103-2 返回 | **必填** | | +| `fdBindType` | 操作类型 | 固定 `ADD`(新增) | **必填** | | + +附件来源:合同主文件 `contractFileJson`(仅 approved=true 的)+ 其它附件 `attachmentsJson`;操作人登录名同 `fdLoginName`;受开关 `attachment.transfer-enabled` 控制。 + +--- + +## 七、依赖配置项(`thirdParty.hcs.*`) + +| 配置 | 用途 | 缺失影响 | +| --- | --- | --- | +| `src-system` | 顶层 fdOuterSystem | 报文缺系统标识 | +| `push.immediate-enabled` | 审批通过即时推送开关(默认 false) | 关闭时仅走扫描兜底 | +| `push.max-retry` / `push.batch-size` | 扫描重试上限 / 批量大小 | — | +| `contract.template-mapping` | contractCategory → HCS 模板ID | **缺失直接推送失败** | +| `contract.settlement-type-mapping` | settlementMode → 结算类型枚举 | 未配置不传 fdSettlementType 并告警 | +| `contract.status-mapping` | approvalStatus → 合同状态 | 未配置不传 CONTRACT_STATUS 并告警 | +| `contract.currency-name-mapping` | 币种名称映射(默认 RMB→人民币) | 未命中传原值 | +| `contract.require-risk-check` | 推送前客商风险校验开关 | — | +| `risk.block-blacklist` | 黑名单阻断付款提交(付款链路);合同链路不再阻断推送,高风险/黑名单转自动特批(§九) | — | +| `attachment.transfer-enabled` | 附件转传开关 | 关闭时不挂附件 | +| `exe-stage.sync-enabled` | C103-9 履约规则同步开关(一期默认关) | — | + +--- + +## 八、TMS 侧留痕字段(`blade_contract_manage`) + +| 字段 | 含义 | +| --- | --- | +| `hcs_fd_id` | 合规合同ID(C103-1 返回的 fdContractId,判重命中也回填) | +| `hcs_sync_status` | unsynced / synced / failed | +| `hcs_retry_count` | 推送重试次数 | +| `hcs_sync_time` / `hcs_sync_msg` | 最近推送时间 / 结果描述 | +| `hcs_risk_level` / `hcs_black_status` / `hcs_risk_time` | 风险校验结果(甲乙双方取先查到的非空风险等级;任一方黑名单即为是) | +| `hcs_spec_status` / `hcs_spec_time` / `hcs_spec_msg` | 特批状态(none/need/processing/approved/rejected)及留痕 | +| `hcs_spec_fd_id` | 特批流程ID(C103-8 返回) | +| `hcs_spec_files_json` | 特批申请材料JSON(含 C103-2 合规附件ID) | +| `current_node` | 挂起展示节点:自动特批后置"特批审核",B103 通过恢复"审批通过",驳回置"已驳回" | + +风险校验明细另写 `blade_hcs_check_log`(甲方/乙方各一条,含 traceId)。 + +--- + +## 九、外部客商高风险/黑名单管控(2026-10-11 增补) + +**管控口径**:内部组织类型客商(`CustomerArchive.customerKind=internal`)默认无风险,不调 C101-1;外部客商(external,含档案不存在兜底按外部处理)命中 **高风险(fdRiskLevel=2)或黑名单(fdBlackStatus=yes)** 时进入管控。甲/乙任一方命中即整单管控。 + +### 9.1 签约前风险查询(展示用) + +`GET /hcs/risk?customerCode=` → `HcsCreditCheckSupport#queryCustomerRisk` → `CustomerRiskVO`: + +| 字段 | 说明 | +| --- | --- | +| `customerKind` | internal / external | +| `fdBlackStatus` / `fdRiskLevel` | C101-1 原值透传 | +| `risky` | 高风险/黑名单判定结果 | +| `riskTip` | 风险提示文案(前端展示在基本信息备注后) | + +提示文案固定模板:`【公司名称】:被列入外部黑名单;涉贪污受贿裁判相关提及方(3年以上及其他);被执行人信息;买卖合同纠纷;劳动纠纷;重大纠纷`(C101-1 未返回风险项明细,联调后如有明细字段再替换)。结果留痕 `blade_hcs_check_log`。 + +### 9.2 提交校验(特批说明附件必传) + +`ContractManageServiceImpl` 三个提交入口(`submit` / `toTemporary` / `submitFormal`)统一调 `validateSpecExplanationOnSubmit`: + +- 甲/乙方按名称反查客商编号(反查不到不阻断,与推送链路 skip 口径一致)→ 调 C101-1 实时查风险; +- 任一方高风险/黑名单 → `attachmentsJson` 必须存在 `attachmentType=spec_explanation` 的附件,否则抛"合同相对方存在高风险/黑名单客商,必须上传「特批说明」类型附件后再提交审批"。 +- **前端配合**:特批说明附件上传时元数据须带 `attachmentType: "spec_explanation"` 字段。 + +### 9.3 审批通过后自动特批链路 + +``` +TMS 审批通过(approved / change_approved) + → 即时推送 / 扫描兜底 → pushContract + → ① 风险校验留痕(risky 标志,不阻断) + → ④ C103-1 起草成功(hcs_sync_status=synced,回填 hcs_fd_id) + → ⑤ 置 hcs_spec_status=need + currentNode="特批审核"(TMS 流程挂起) + → 筛「特批说明」附件转传 C103-2 → 自动发起 C103-8 → hcs_spec_status=processing +``` + +- 原设计"黑名单挂起不推送、人工发起特批"存在死锁(挂起时无 `hcs_fd_id`,C103-8 报文必填合规合同ID 永远失败),本次变更为 **先 C103-1 起草、后 C103-8 特批**,特批通过即合规侧放行签约,无需恢复推送; +- 特批已通过(approved,单次有效)的合同推送/变更不重复发起特批; +- C103-8 发起失败保持 need 仅留痕,人工经 `/hcs/contract/spec-apply`(详情页"发起特批"按钮)重发。 + +### 9.4 特批结果联动(B103 回传 / 人工确认等效) + +| 特批结果 | 合同联动 | +| --- | --- | +| approved | `currentNode` 恢复"审批通过"(C103-1 已起草,hcs_sync_status 保持 synced,无需再推送) | +| rejected | `approvalStatus=rejected` + `currentNode="已驳回"`,走重新编辑 → 提交 → 审批 → 重新特批闭环(重新推送时 C103-11 更新后再次自动特批) | + +> 缺口:v4 无特批结果主动查询接口(§9-U1),结果依赖 B103 回调(`POST /hcs/callback/spec-result`)或页面人工确认(`POST /hcs/contract/spec/confirm`),合规方提供查询能力后可增轮询兜底。