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`),合规方提供查询能力后可增轮询兜底。