diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java index 7398b13f..782c1b7c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java @@ -238,4 +238,21 @@ public class ContractManage extends TenantEntity { @Schema(description = "合规风险校验时间") private LocalDateTime hcsRiskTime; + @Schema(description = "合规特批流程ID(C103-8 返回)") + private String hcsSpecFdId; + + @Schema(description = "合规特批状态 none/need/processing/approved/rejected") + @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS) + private String hcsSpecStatus; + + @Schema(description = "合规最近特批动作时间") + private LocalDateTime hcsSpecTime; + + @Schema(description = "合规特批结果/发起失败原因") + @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS) + private String hcsSpecMsg; + + @Schema(description = "特批申请材料+内部审批附件JSON(含C103-2 合规附件ID)") + private String hcsSpecFilesJson; + } 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 c0714e09..c9b62024 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 @@ -84,6 +84,11 @@ public final class HcsSyncConstant { */ public static final String CHECK_TYPE_CREDIT = "credit"; + /** + * 校验类型:合同特批(C103-8 发起 / B103 结果回传留痕) + */ + public static final String CHECK_TYPE_SPEC = "spec"; + /** * 校验结果:通过 */ 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 10c8f586..7534c9c6 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 @@ -26,14 +26,27 @@ package org.springblade.transport.controller; import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import feign.Response; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springblade.core.secure.annotation.PreAuth; +import org.springblade.core.secure.constant.AuthConstant; +import org.springblade.core.secure.utils.AuthUtil; 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.support.hcs.HcsAttachmentSupport; +import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport; import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsCreditCheckSupport; import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport; import org.springblade.transport.support.hcs.HcsPaymentSyncSupport; import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport; @@ -41,12 +54,22 @@ import org.springblade.transport.support.hcs.HcsReconSupport; import org.springblade.transport.support.hcs.HcsMasterDataSupport; import org.springblade.transport.support.hcs.HcsProjectSyncSupport; import org.springblade.transport.task.HcsPushTask; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; /** * HCS 合规推送端点(设计文档 §5.0 手动入口) @@ -67,6 +90,9 @@ public class HcsSyncController { private final HcsPushTask hcsPushTask; private final HcsProjectSyncSupport hcsProjectSyncSupport; private final HcsContractSyncSupport hcsContractSyncSupport; + private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport; + private final HcsCreditCheckSupport hcsCreditCheckSupport; + private final HcsAttachmentSupport hcsAttachmentSupport; private final HcsPaymentSyncSupport hcsPaymentSyncSupport; private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport; private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport; @@ -171,4 +197,118 @@ public class HcsSyncController { public R reconInvoiceRegistration(@Parameter(description = "开票申请单号", required = true) @RequestParam String applicationNo) { return R.data(hcsReconSupport.reconInvoiceRegistration(applicationNo)); } + + /** + * 合规模板列表(C103-6,Redis 缓存优先,前端接入设计 §3.1) + */ + @GetMapping("/master-data/template") + @ApiOperationSupport(order = 9) + @Operation(summary = "合规模板列表查询", description = "C103-6 模板列表,走服务端 Redis 缓存,未命中自动全量拉取") + public R> templates() { + return R.data(hcsMasterDataSupport.getTemplates()); + } + + /** + * 合规币种列表(C103-12,Redis 缓存优先,前端接入设计 §3.1) + */ + @GetMapping("/master-data/currency") + @ApiOperationSupport(order = 16) + @Operation(summary = "合规币种列表查询", description = "C103-12 币种列表,走服务端 Redis 缓存,未命中自动全量拉取") + public R> currencies() { + return R.data(hcsMasterDataSupport.getCurrencies()); + } + + /** + * 客商风险等级查询(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)); + } + + /** + * 附件上传到合同系统(C103-2 代理,前端接入设计 §3.2),返回合规附件ID + */ + @PostMapping(value = "/attachment/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ApiOperationSupport(order = 11) + @Operation(summary = "附件上传到合同系统", description = "C103-2 代理:单文件上传至合规,返回合规附件ID(fdId)") + public R uploadAttachment(@Parameter(description = "附件文件", required = true) @RequestParam("fdFile") MultipartFile fdFile) { + return R.data(hcsAttachmentSupport.uploadOne(fdFile, AuthUtil.getUserAccount())); + } + + /** + * 合同附件下载(C103-5 代理,前端接入设计 §3.2):文件流透传浏览器 + */ + @GetMapping("/attachment/download") + @ApiOperationSupport(order = 12) + @Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名") + public void downloadAttachment(@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId, + @Parameter(description = "下载文件名(可选)") @RequestParam(required = false) String name, + HttpServletResponse response) throws Exception { + String fileName = StringUtil.isBlank(name) ? "hcs-attachment-" + fdId : name; + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")); + try (Response hcsResponse = hcsAttachmentSupport.downloadRaw(fdId); + InputStream inputStream = hcsResponse.body().asInputStream(); + OutputStream outputStream = response.getOutputStream()) { + inputStream.transferTo(outputStream); + outputStream.flush(); + } + } + + /** + * 合同发起特批(C103-8,前端接入设计 §3.4):body = { contractId, attachments: [{fdId, name}] } + */ + @PostMapping("/contract/spec-apply") + @ApiOperationSupport(order = 13) + @Operation(summary = "合同发起特批", description = "C103-8:高风险客商合同特批发起,attachments 为经 /hcs/attachment/upload 上传的合规附件引用") + @SuppressWarnings("unchecked") + public R applyContractSpec(@RequestBody Map body) { + Object contractIdValue = body == null ? null : body.get("contractId"); + if (contractIdValue == null || StringUtil.isBlank(String.valueOf(contractIdValue))) { + return R.fail("contractId 不能为空"); + } + Long contractId = Long.valueOf(String.valueOf(contractIdValue)); + Object attachmentsValue = body == null ? null : body.get("attachments"); + List attachments = (attachmentsValue instanceof List list ? list : List.of()).stream() + .filter(Map.class::isInstance) + .map(item -> { + Map attachment = (Map) item; + HcsAttachmentRefDTO ref = new HcsAttachmentRefDTO(); + // 附件ID:C103-2 上传返回的合规附件ID + ref.setFdId(String.valueOf(attachment.get("fdId"))); + // 附件名 + ref.setFdFileName(attachment.get("name") == null ? null : String.valueOf(attachment.get("name"))); + return ref; + }) + .toList(); + return R.data(hcsContractSpecSyncSupport.applySpec(contractId, attachments, AuthUtil.getUserAccount())); + } + + /** + * 合同特批结果人工确认(§6-U1 过渡方案:B103 报文未到位前与回调等效) + */ + @PostMapping("/contract/spec/confirm") + @ApiOperationSupport(order = 14) + @Operation(summary = "合同特批结果人工确认", description = "传入合同ID 与结果(approved/rejected),仅特批中(processing)允许确认") + public R confirmContractSpec(@Parameter(description = "合同ID", required = true) @RequestParam Long id, + @Parameter(description = "确认结果 approved/rejected", required = true) @RequestParam String result) { + return R.data(hcsContractSpecSyncSupport.confirmSpecResult(id, result)); + } + + /** + * B103 特批结果回传接收(合规系统→TMS 回调,公开端点 + 原文留痕,前端接入设计 §3.4) + *

+ * TODO 待联调(§6-U1):B103 报文未定,当前按候选字段(contractNo/fdId/result 等)解析, + * 解析失败仅留痕不置状态,报文口径确认后完善解析。 + */ + @PostMapping("/callback/spec-result") + @PreAuth(AuthConstant.PERMIT_ALL) + @ApiOperationSupport(order = 15) + @Operation(summary = "B103特批结果回传", description = "合规系统回调:合同特批结果回传接收,原文留痕并回写合同特批状态") + public R specResultCallback(@RequestBody(required = false) Map body) { + return R.data(hcsContractSpecSyncSupport.handleSpecCallback(body)); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java index e505f910..4e1351ca 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java @@ -33,6 +33,7 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.StringUtil; import org.springblade.hcs.config.HcsProperties; import org.springblade.hcs.constant.HcsConstant; +import org.springblade.hcs.dto.HcsAttachmentDownloadDTO; import org.springblade.hcs.dto.HcsAttachmentVO; import org.springblade.hcs.dto.HcsResult; import org.springblade.hcs.exception.HcsException; @@ -123,6 +124,44 @@ public class HcsAttachmentSupport { return attachmentFdIds; } + /** + * 前端单文件直传合规(C103-2 代理,前端接入设计 §3.2):返回合规附件ID; + * 不写映射缓存(无 TMS 附件元数据键),调用方自行持久化 fdId(如特批材料 hcs_spec_files_json) + * + * @param file 上传文件 + * @param loginName 操作人登录名(fdLoginName) + * @return 合规附件ID + */ + public String uploadOne(MultipartFile file, String loginName) { + if (file == null || file.isEmpty()) { + throw new HcsException("HCS附件上传失败:文件为空"); + } + HcsResult result = hcsClient.uploadAttachment(file, "false", loginName); + if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) { + log.error("HCS附件上传失败响应:{}", result == null ? "无响应" : String.format("code=%s, msg=%s, traceId=%s", + result.getCode(), result.getMsg(), result.getTraceId())); + throw new HcsException("HCS附件上传失败:" + (result == null ? "响应为空" : result.getMsg())); + } + return result.getData().getFdId(); + } + + /** + * 合规附件下载(C103-5 代理,前端接入设计 §3.2):返回原始文件流响应,由控制器透传浏览器 + * + * @param fdId 合规附件ID + * @return Feign 文件流响应(调用方负责关闭) + */ + public feign.Response downloadRaw(String fdId) { + HcsAttachmentDownloadDTO param = new HcsAttachmentDownloadDTO(); + // 【必填】附件ID:上传时返回的 fdId + param.setFdId(fdId); + feign.Response response = hcsClient.downloadAttachment(param); + if (response == null || response.body() == null) { + throw new HcsException("HCS附件下载失败:响应为空(fdId=" + fdId + ")"); + } + return response; + } + /** * 转传单个附件:映射缓存命中直接返回,未命中下载上传后写缓存 */ 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 new file mode 100644 index 00000000..f4773258 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSpecSyncSupport.java @@ -0,0 +1,352 @@ +/** + * 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 cn.hutool.json.JSONUtil; +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.utils.StringUtil; +import org.springblade.hcs.dto.HcsAttachmentRefDTO; +import org.springblade.hcs.dto.HcsMechanismsDTO; +import org.springblade.hcs.dto.HcsResult; +import org.springblade.hcs.dto.HcsSpecContractSaveDTO; +import org.springblade.hcs.dto.HcsFormSaveVO; +import org.springblade.hcs.exception.HcsException; +import org.springblade.hcs.feign.IHcsClient; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.HcsContractCheckLogMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.HcsContractCheckLog; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * HCS 合同特批同步支撑组件(C103-8 发起特批 / B103 特批结果回传,前端接入设计 §3.4) + *

+ * 业务闭环:合同推送时 C101-1 风险校验发现黑名单 → 置 hcs_spec_status=need 挂起(推送链路门禁跳过)→ + * 内部特批审批通过后,前端上传特批材料(C103-2 取合规附件ID)并发起 C103-8 → 状态 processing(流程挂起)→ + * 集团审批结果经 B103 回传 TMS → approved 后既有扫描任务自动恢复 C103-1 推送,rejected 可重新发起。 + *

+ * TODO 待联调确认(前端接入设计 §6-U1):C103-8 与 B103 报文 v4 均未提供, + * C103-8 按 C106-3 模式预估(双层嵌套返回),B103 回调按候选字段解析、原文留痕 blade_hcs_check_log。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsContractSpecSyncSupport { + + /** + * 特批附件字段标识(照 C106-3 fd_spc_approval_attach 惯例预估,§6-U1 以合规方报文为准) + */ + private static final String SPEC_ATTACH_ENTITY_KEY = "fd_spc_approval_attach"; + + /** + * B103 原文留痕最大长度(blade_hcs_check_log.msg 为 varchar(512)) + */ + private static final int CALLBACK_MSG_MAX_LENGTH = 500; + + private final IHcsClient hcsClient; + private final ContractManageMapper contractManageMapper; + private final HcsContractCheckLogMapper hcsContractCheckLogMapper; + + /** + * 发起合同特批(C103-8):校验合同与特批状态 → 组装预估报文 → 回填特批流程ID,状态置 processing + * + * @param contractId 合同ID + * @param attachments 特批附件引用(fdId=C103-2 合规附件ID,fdFileName=文件名) + * @param loginName 当前操作人登录名 + * @return 结果描述 + */ + public String applySpec(Long contractId, List attachments, String loginName) { + ContractManage contractManage = requireAppliableContract(contractId); + if (attachments == null || attachments.isEmpty()) { + throw new ServiceException("请先上传特批申请材料(含内部审批流程附件)"); + } + HcsSpecContractSaveDTO param = new HcsSpecContractSaveDTO(); + // 【必填】操作人登录名:当前登录人(§9-U2) + param.setFdLoginName(loginName); + // 【必填】合规合同ID:blade_contract_manage.hcs_fd_id(非名称,照 C106-3 fd_contract_name 惯例) + param.setFdContractId(requireContractFdId(contractManage)); + // 【必填】合同编号 + param.setFdContractNo(contractManage.getContractNo()); + // 【必填】单据主题:"合同特批-" + 合同编号 + param.setFdDocSubject("合同特批-" + contractManage.getContractNo()); + // 特批事由:合同名称(非必传) + param.setFdReason(contractManage.getContractName()); + // 特批附件:发起带 fdBindType=ADD(entityKey 预估照 C106-3,§6-U1) + param.setMechanisms(buildMechanisms(attachments)); + // 特批材料留痕(含合规附件ID,供重新发起与排查) + contractManage.setHcsSpecFilesJson(JSONUtil.toJsonStr(attachments)); + try { + log.info("HCS合同特批发起开始:contractNo={},附件 {} 个", contractManage.getContractNo(), attachments.size()); + // TODO 待联调(§6-U1):报文按 C106-3 模式预估,双层嵌套 data.data.fdId + HcsResult> result = hcsClient.addSpecContract(param); + String specFdId = extractNestedFdId(contractManage, result); + contractManage.setHcsSpecFdId(specFdId); + markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_PROCESSING, "C103-8 合同特批发起成功"); + writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_PASS, "C103-8 特批发起成功,特批流程ID=" + specFdId, result.getTraceId()); + log.info("HCS合同特批发起成功:contractNo={},specFdId={}", contractManage.getContractNo(), specFdId); + return "特批发起成功,等待集团审批"; + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "特批发起异常" : exception.getMessage(); + // 失败保持待特批(need),允许重试发起 + markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_NEED, "C103-8 特批发起失败:" + message); + writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_FAIL, "C103-8 特批发起失败:" + message, null); + throw exception instanceof ServiceException serviceException ? serviceException + : new ServiceException("合同特批发起失败:" + message); + } + } + + /** + * 特批结果人工确认(§6-U1 过渡方案:B103 报文未到位前,页面确认与回调等效) + * + * @param id 合同ID + * @param result approved/rejected + * @return 结果描述 + */ + public String confirmSpecResult(Long id, String result) { + ContractManage contractManage = contractManageMapper.selectById(id); + if (contractManage == null) { + throw new ServiceException("合同单不存在:" + id); + } + if (!List.of(HcsSyncConstant.SPEC_STATUS_APPROVED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(result)) { + throw new ServiceException("特批结果仅支持 approved/rejected"); + } + if (!HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())) { + throw new ServiceException("仅特批中的合同允许确认结果(当前状态:" + contractManage.getHcsSpecStatus() + ")"); + } + markSpec(contractManage, result, "特批结果人工确认:" + result); + writeCheckLog(contractManage, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result) + ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL, "特批结果人工确认:" + result, null); + log.info("HCS合同特批结果人工确认:contractNo={},specFdId={},结果={}", contractManage.getContractNo(), contractManage.getHcsSpecFdId(), result); + return "特批结果已确认:" + result; + } + + /** + * B103 特批结果回传处理(回调端点入口):候选字段解析 + 原文留痕, + * approved 时合同保持待同步(unsynced),由既有扫描任务自动恢复 C103-1 推送 + * + * @param body B103 回调报文(结构待联调,原文全量留痕) + * @return 处理结果描述 + */ + public String handleSpecCallback(Map body) { + String raw = body == null ? "" : JSONUtil.toJsonStr(body); + String contractNo = firstNonBlank(body, "fdContractNo", "contractNo", "fd_contract_no", "fdNumber"); + String specFdId = firstNonBlank(body, "fdId", "fdSpecId", "fd_spec_id", "specFdId"); + String resultText = firstNonBlank(body, "result", "fdResult", "fd_result", "approvalResult", "status"); + ContractManage contractManage = locateContract(contractNo, specFdId); + if (contractManage == null) { + log.warn("B103特批结果回传未定位到合同:contractNo={},specFdId={},报文已留痕", contractNo, specFdId); + writeCallbackLog(null, HcsSyncConstant.CHECK_RESULT_SKIP, "B103 回传未定位到合同:" + raw); + return "已留痕:未定位到合同(contractNo=" + contractNo + ")"; + } + Boolean approved = parseSpecResult(resultText); + if (approved == null) { + log.warn("B103特批结果字段未识别:contractNo={},resultText={},报文已留痕", contractManage.getContractNo(), resultText); + writeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, "B103 回传结果字段未识别:" + raw); + return "已留痕:结果字段未识别(contractNo=" + contractManage.getContractNo() + ")"; + } + String status = approved ? HcsSyncConstant.SPEC_STATUS_APPROVED : HcsSyncConstant.SPEC_STATUS_REJECTED; + markSpec(contractManage, status, "B103 特批结果回传:" + raw); + writeCallbackLog(contractManage, approved ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL, + "B103 特批结果回传:" + raw); + log.info("B103特批结果回传处理完成:contractNo={},结果={}", contractManage.getContractNo(), status); + return "特批结果已处理:" + status; + } + + /** + * 校验可发起特批的合同:存在、审批通过、特批状态为待特批/已驳回 + */ + private ContractManage requireAppliableContract(Long contractId) { + ContractManage contractManage = contractManageMapper.selectById(contractId); + if (contractManage == null) { + throw new ServiceException("合同单不存在:" + contractId); + } + if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) { + throw new ServiceException("仅审批通过的合同可发起特批(当前状态:" + contractManage.getApprovalStatus() + ")"); + } + if (!List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(contractManage.getHcsSpecStatus())) { + throw new ServiceException("仅待特批/已驳回的合同可发起特批(当前状态:" + contractManage.getHcsSpecStatus() + ")"); + } + return contractManage; + } + + /** + * 合规合同ID(合同须已推送合规回填 hcs_fd_id) + */ + private String requireContractFdId(ContractManage contractManage) { + if (StringUtil.isBlank(contractManage.getHcsFdId())) { + throw new ServiceException("合同尚未推送合规,无法发起特批(contractNo=" + contractManage.getContractNo() + ")"); + } + return contractManage.getHcsFdId(); + } + + /** + * 特批附件挂载(发起带 fdBindType=ADD) + */ + private HcsMechanismsDTO buildMechanisms(List attachments) { + HcsMechanismsDTO mechanisms = new HcsMechanismsDTO(); + mechanisms.setAttachment(attachments.stream().map(ref -> { + HcsAttachmentRefDTO attachment = new HcsAttachmentRefDTO(); + // 附件ID:C103-2 转传返回的合规附件ID + attachment.setFdId(ref.getFdId()); + // 附件名(预估字段,§6-U1) + attachment.setFdFileName(ref.getFdFileName()); + // 特批附件字段标识 + attachment.setFdEntityKey(SPEC_ATTACH_ENTITY_KEY); + // 绑定类型:发起传 ADD + attachment.setFdBindType("ADD"); + return attachment; + }).toList()); + return mechanisms; + } + + /** + * 解析 C103-8 双层嵌套返回 data.data.fdId(照 C106-3 模式预估) + */ + private String extractNestedFdId(ContractManage contractManage, HcsResult> result) { + if (result == null || !result.isSuccess() || result.getData() == null || !result.getData().isSuccess() + || result.getData().getData() == null || StringUtil.isBlank(result.getData().getData().getFdId())) { + log.error("HCS合同特批发起失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo()); + throw new HcsException("C103-8 合同特批发起失败:" + (result == null ? "响应为空" : result.getMsg())); + } + return result.getData().getData().getFdId(); + } + + /** + * 按合同编号/特批流程ID 定位合同(B103 报文候选键) + */ + private ContractManage locateContract(String contractNo, String specFdId) { + if (StringUtil.isNotBlank(contractNo)) { + ContractManage contractManage = contractManageMapper.selectOne(Wrappers.lambdaQuery() + .eq(ContractManage::getContractNo, contractNo) + .eq(ContractManage::getIsDeleted, 0) + .last("limit 1")); + if (contractManage != null) { + return contractManage; + } + } + if (StringUtil.isNotBlank(specFdId)) { + return contractManageMapper.selectOne(Wrappers.lambdaQuery() + .eq(ContractManage::getHcsSpecFdId, specFdId) + .eq(ContractManage::getIsDeleted, 0) + .last("limit 1")); + } + return null; + } + + /** + * 解析特批结果文本:approved/通过/pass/true → true;rejected/驳回/refuse/false → false;未识别 → null + */ + private Boolean parseSpecResult(String resultText) { + if (StringUtil.isBlank(resultText)) { + return null; + } + String text = resultText.trim().toLowerCase(); + if (List.of("approved", "pass", "true", "通过", "审批通过", "同意").contains(text)) { + return Boolean.TRUE; + } + if (List.of("rejected", "refuse", "refused", "false", "驳回", "不通过", "不同意").contains(text)) { + return Boolean.FALSE; + } + return null; + } + + /** + * 从候选字段名依次取首个非空字符串值 + */ + private String firstNonBlank(Map body, String... keys) { + if (body == null) { + return null; + } + for (String key : keys) { + Object value = body.get(key); + if (value != null && StringUtil.isNotBlank(String.valueOf(value))) { + return String.valueOf(value); + } + } + return null; + } + + /** + * 回写特批状态与动作时间 + */ + private void markSpec(ContractManage contractManage, String status, String message) { + contractManage.setHcsSpecStatus(status); + contractManage.setHcsSpecTime(LocalDateTime.now()); + contractManage.setHcsSpecMsg(StringUtil.left(message, 500)); + contractManageMapper.updateById(contractManage); + } + + /** + * 特批留痕(blade_hcs_check_log,biz_type=contract,check_type=spec,异常不影响主流程) + */ + private void writeCheckLog(ContractManage contractManage, String result, String msg, String traceId) { + try { + HcsContractCheckLog checkLog = new HcsContractCheckLog(); + checkLog.setTenantId(contractManage.getTenantId()); + checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT); + checkLog.setBizId(contractManage.getId()); + checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC); + checkLog.setResult(result); + checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH)); + checkLog.setTraceId(traceId); + checkLog.setCreateTime(LocalDateTime.now()); + hcsContractCheckLogMapper.insert(checkLog); + } catch (Exception exception) { + log.error("HCS合同特批留痕写入失败:contractNo={}", contractManage.getContractNo(), exception); + } + } + + /** + * B103 回传留痕(合同未定位到时 bizId 为空,原文留痕便于联调解析) + */ + private void writeCallbackLog(ContractManage contractManage, String result, String msg) { + if (contractManage == null) { + try { + HcsContractCheckLog checkLog = new HcsContractCheckLog(); + checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT); + checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC); + checkLog.setResult(result); + checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH)); + checkLog.setCreateTime(LocalDateTime.now()); + hcsContractCheckLogMapper.insert(checkLog); + } catch (Exception exception) { + log.error("B103回传留痕写入失败", exception); + } + return; + } + writeCheckLog(contractManage, result, msg, null); + } +} 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 f98a1e61..6ade9dc4 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 @@ -100,15 +100,22 @@ public class HcsContractSyncSupport { private final HcsExeStageSyncSupport hcsExeStageSyncSupport; /** - * 推送合同到 HCS:风险校验 → 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced + * 推送合同到 HCS:特批挂起门禁 → 风险校验(黑名单挂起待特批)→ 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced * * @param contractManage 合同单(approvalStatus 须为 approved/change_approved) * @return 推送结果描述(供任务汇总与 hcs_sync_msg 落库) */ public String pushContract(ContractManage contractManage) { - // ① 客商风险校验(可配开关),黑名单且 block-blacklist=true 时阻断推送 + // ⓪ 特批挂起门禁:待特批/特批中的合同跳过推送,等待 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 时挂起待特批(不再直接失败) if (hcsProperties.getContract().isRequireRiskCheck()) { - checkRelativeRisk(contractManage); + if (checkRelativeRisk(contractManage)) { + return "高风险客商,合同已挂起待特批(C103-8)"; + } } // ② 附件转传(先转传并缓存合规附件ID,C103-1 挂载方式待 §9-U8 确认后接线) transferAttachmentsIfEnabled(contractManage); @@ -139,9 +146,13 @@ public class HcsContractSyncSupport { } /** - * C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段,黑名单阻断 + * C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段; + * 黑名单且 block-blacklist=true 时不再直接失败,而是置 hcs_spec_status=need 挂起待特批(C103-8); + * 特批已通过(approved,单次有效)的合同不重复挂起,保证 B103 回传后推送链路可走通 + * + * @return true=黑名单挂起待特批(调用方跳过本次推送) */ - private void checkRelativeRisk(ContractManage contractManage) { + private boolean checkRelativeRisk(ContractManage contractManage) { RiskResult riskPartyA = queryRisk(contractManage, "甲方", contractManage.getPartyA()); RiskResult riskPartyB = queryRisk(contractManage, "乙方", contractManage.getPartyB()); // 风险等级:甲乙双方取先查到的非空值(无法量化比较,留痕双方原始值,联调后细化) @@ -155,11 +166,17 @@ public class HcsContractSyncSupport { log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}]", contractManage.getContractNo(), contractManage.getPartyA(), riskPartyA.blackStatus(), riskPartyA.riskLevel(), contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel()); - if (blacklist && hcsProperties.getRisk().isBlockBlacklist()) { - String message = "客商黑名单,禁止推送(甲方[" + riskPartyA.blackStatus() + "]乙方[" + riskPartyB.blackStatus() + "])"; - log.error("HCS合同推送被风险校验阻断:contractNo={},{}", contractManage.getContractNo(), message); - throw new HcsException(message); + 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; } + return false; } /** @@ -367,6 +384,9 @@ public class HcsContractSyncSupport { if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) { throw new ServiceException("仅审批通过的合同可推送合规(当前状态:" + contractManage.getApprovalStatus() + ")"); } + if (List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(contractManage.getHcsSpecStatus())) { + throw new ServiceException("高风险客商合同需先完成特批(C103-8,当前特批状态:" + contractManage.getHcsSpecStatus() + ")"); + } contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); contractManage.setHcsRetryCount(0); contractManageMapper.updateById(contractManage); 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 371098cb..eb72f01f 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 @@ -170,6 +170,44 @@ public class HcsCreditCheckSupport { log.warn("HCS付款提交额度超额(转特批):paymentNo={},{}", payment.getPaymentNo(), message); } + /** + * 前端客商风险查询(C101-1,展示用,前端接入设计 §3.3): + * 按客商编号查询风险等级与黑名单状态并留痕(biz_id 为空);仅用于展示提示,不作为安全边界 + * + * @param customerCode 客商编号 + * @return 风险信息(fdBlackStatus/fdRiskLevel) + */ + public HcsRiskVO queryRiskByCustomerCode(String customerCode) { + if (StringUtil.isBlank(customerCode)) { + throw new ServiceException("客商编号不能为空"); + } + HcsRiskQueryDTO param = new HcsRiskQueryDTO(); + // 【必填】客商编号(用哪个编码待联调,§9-U6) + param.setFdNumber(customerCode); + HcsResult result = hcsClient.getRelativeRisk(param); + if (result == null || !result.isSuccess()) { + log.error("HCS客商风险查询失败响应:{},fdNumber={}", + result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), customerCode); + throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg())); + } + HcsRiskVO risk = result.getData(); + String blackStatus = risk == null ? null : risk.getFdBlackStatus(); + boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus); + 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.setTraceId(result.getTraceId()); + checkLog.setCreateTime(LocalDateTime.now()); + hcsContractCheckLogMapper.insert(checkLog); + } catch (Exception exception) { + log.error("HCS页面风险查询留痕写入失败:fdNumber={}", customerCode, exception); + } + return risk; + } + /** * 按名称反查客商编码(照 KingdeeApBillSyncSupport#queryCustomerCode 惯例,查不到返回 null) */ diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecContractSaveDTO.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecContractSaveDTO.java new file mode 100644 index 00000000..24b275ba --- /dev/null +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/dto/HcsSpecContractSaveDTO.java @@ -0,0 +1,77 @@ +/** + * 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.hcs.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 合同合规系统(HCS)C103-8 合同发起特批流程入参 + *

+ * TODO 待联调确认(前端接入设计 §6-U1):C103-8 报文 v4 未提供,本 DTO 按 C106-3 结算付款特批的 + * formData/mechanisms 模式预估,字段名以合规方最新接口说明为准。 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class HcsSpecContractSaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 特批流程ID(更新必填,发起不传) + */ + private String fdId; + /** + * 【必填】操作人登录名(§9-U2) + */ + private String fdLoginName; + /** + * 【必填】合规合同ID(blade_contract_manage.hcs_fd_id,非名称) + */ + private String fdContractId; + /** + * 【必填】合同编号 + */ + private String fdContractNo; + /** + * 【必填】单据主题:"合同特批-" + 合同编号 + */ + private String fdDocSubject; + /** + * 特批事由(合同名称/特批原因,非必传) + */ + private String fdReason; + /** + * 特批附件(C103-2 转传返回的合规附件ID 引用) + */ + private HcsMechanismsDTO mechanisms; +} diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java index cff70f6a..503bc9df 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/feign/IHcsClient.java @@ -54,6 +54,7 @@ import org.springblade.hcs.dto.HcsReceiptBillSaveDTO; import org.springblade.hcs.dto.HcsResult; import org.springblade.hcs.dto.HcsRiskQueryDTO; import org.springblade.hcs.dto.HcsRiskVO; +import org.springblade.hcs.dto.HcsSpecContractSaveDTO; import org.springblade.hcs.dto.HcsSpecPaySaveDTO; import org.springblade.hcs.dto.HcsTemplateVO; import org.springframework.cloud.openfeign.FeignClient; @@ -97,6 +98,7 @@ public interface IHcsClient { String CREDIT_CHECK_URL = "${thirdParty.hcs.creditCheckUrl:/openapi/eop-cdraft/lcode/ext-cdraft/extEopCdraftMainOpen/contractCreditCheck}"; String SPEC_PAY_ADD_URL = "${thirdParty.hcs.specPayAddUrl:/openapi/sys-modeling/lcode/ext-pay-receive/extPayReceiveOpenService/addSpecApplyPay}"; String SPEC_PAY_UPDATE_URL = "${thirdParty.hcs.specPayUpdateUrl:/openapi/sys-modeling/lcode/ext-pay-receive/extPayReceiveOpenService/updateSpecApplyPay}"; + String SPEC_CONTRACT_ADD_URL = "${thirdParty.hcs.specContractAddUrl:/openapi/sys-modeling/lcode/ext-cdraft/extEopCdraftMainOpen/addSpecApplyContract}"; String PERF_RECORD_ADD_URL = "${thirdParty.hcs.perfRecordAddUrl:/openapi/sys-modeling/apis/data/km-pay-receive/V1/dataSync/add}"; String PERF_RECORD_UPDATE_URL = "${thirdParty.hcs.perfRecordUpdateUrl:/openapi/sys-modeling/apis/data/km-pay-receive/V1/dataSync/update}"; String RECEIPT_BILL_ADD_URL = "${thirdParty.hcs.receiptBillAddUrl:/openapi/sys-modeling/lcode/ext-invoice-receive/extInvoiceReceiveOpenService/addReceiveBill}"; @@ -264,6 +266,19 @@ public interface IHcsClient { @PostMapping(SPEC_PAY_UPDATE_URL) HcsResult> updateSpecApplyPay(@RequestBody HcsSpecPaySaveDTO param); + /** + * C103-8 合同发起特批流程(高风险客商签约特批) + *

+ * TODO 待联调确认(前端接入设计 §6-U1):C103-8 报文与路径均未在 v4 接口说明中定义, + * 本端点按 C106-3 结算付款特批的报文结构预估(双层嵌套 data.data 承接返回), + * 路径可经 thirdParty.hcs.specContractAddUrl 覆盖,字段以合规方最新接口说明为准。 + * + * @param param 请求参数 + * @return data.data.{fdId, fdXFormId, docViewUrl}(fdId 为特批流程ID) + */ + @PostMapping(SPEC_CONTRACT_ADD_URL) + HcsResult> addSpecContract(@RequestBody HcsSpecContractSaveDTO param); + /** * C108-1 履约记录新建 * diff --git a/doc/docs/hcs合同/2026-10-09-项目立项与合同HCS前端接入设计.md b/doc/docs/hcs合同/2026-10-09-项目立项与合同HCS前端接入设计.md new file mode 100644 index 00000000..7fb368f3 --- /dev/null +++ b/doc/docs/hcs合同/2026-10-09-项目立项与合同HCS前端接入设计.md @@ -0,0 +1,254 @@ +# 项目立项与合同 HCS 前端接入设计(tms-erp-web) + +> **日期**:2026-10-09 +> **依据**:《桂物合同集成相关接口说明-v4.docx》、[TMS业财融合方案.docx](../TMS业财融合方案.docx)、[2026-09-27-桂物合同合规系统HCS接入技术设计.md](./2026-09-27-桂物合同合规系统HCS接入技术设计.md)、[2026-09-28-HCS接入实施状态与确认清单.md](./2026-09-28-HCS接入实施状态与确认清单.md) +> **涉及工程**:`tms-api`(后端补前端代理端点)、`tms-erp-web`(前端页面与 API 模块) + +--- + +## 1. 总体原则 + +| # | 原则 | +|---|---| +| P1 | **前端不直连 HCS**。所有 C 系列接口由 tms-api 代理:`Authorization`/`Key-Auth` 静态双头只存在于服务端,浏览器不可见,也无跨域问题 | +| P2 | **自动推送不动**。C102-1/2、C103-1/11 仍走"审批通过 → 置 unsynced → 扫描任务推送"链路(零埋点,ADR-0001),前端只补**状态展示 + 手动重推 + 对账回查** | +| P3 | **附件主存储仍在 TMS OSS**。表单上传逻辑不改;HCS 转传由后端推送链路自动完成(HcsAttachmentSupport 已实现)。前端新增的是**手动转传**(特批材料、确定性上传)与**HCS 附件回下载** | +| P4 | **前端结算币种/合同分类不改**。两个下拉继续用本地字典(`currency_type`/`contractCategory`),存量值与交互零变化;C103-12 币种、C103-7 分类只在后端推送侧映射(`currency-name-mapping`/`template-mapping`,已实现),前端无触点 | +| P5 | UI 照**"同步金蝶"按钮模式**(参考 `customer-archive.vue`、`formal-settlement-table-panel.vue` 的 kingdeeSyncStatus 列 + 推送按钮),HCS 用 `hcsSyncStatus` 等同构字段 | + +--- + +## 2. 接口 → 前端触点映射总表 + +| HCS 接口 | 后端现状 | 面向前端端点 | 前端触点 | +|---|---|---|---| +| C101-1 客商风险等级 | Feign 已有,合同推送/付款提交时服务端调用 | **`GET /hcs/risk`(新增)** | 合同表单选甲乙方后展示风险标签;高风险+黑名单给"发起特批"入口 | +| C102-1 项目立项新增 | 扫描任务自动推送 | — | 无直接触点(列表展示同步结果) | +| C102-2 项目立项更新 | 变更审批通过自动推送 | — | 同上 | +| C102-3 项目立项查询 | `GET /hcs/recon/project` 已有 | 已有 | 立项列表"合规对账"弹窗 | +| C103-12 币种列表 | Feign + Redis 缓存已有(推送侧映射用) | **移出前端范围**(2026-10-09 确认:前端结算币种沿用本地字典,无触点) | — | +| C103-6 模板列表 | Feign + Redis 缓存已有 | **`GET /hcs/master-data/template`(新增)** | 管理端"合规主数据/映射"查看页(表单不选模板,template-mapping 在 Nacos) | +| ~~C103-7 分类列表~~ | Feign + 缓存已有 | **移出前端范围**(2026-10-09 确认:分类为推送侧 Nacos 映射的前置,前端无界面触点) | — | +| C103-1 合同同步 | 审批通过自动推送 | `POST /hcs/sync/contract/{id}` 已有 | 合同列表"推送合规"按钮 | +| C103-11 合同信息更新 | 变更审批通过自动推送 | 同上(重推入口) | 同上 | +| C103-2 附件上传 | Feign 已有,推送链路自动转传 | **`POST /hcs/attachment/upload`(新增)** | 合同/特批材料"上传到合同系统"手动操作,返回 fdId 落库 | +| C103-3 合同列表查询 | `GET /hcs/recon/contract` 已有 | 已有 | 合同列表"合规对账"弹窗 | +| C103-4 合同详情查询 | `GET /hcs/recon/contract/detail` 已有 | 已有 | 对账弹窗"查看合规详情" | +| C103-5 合同附件下载 | 仅 Feign 端点 | **`GET /hcs/attachment/download`(新增)** | 对账弹窗/合同详情中下载 HCS 侧附件 | +| C103-8 合同发起特批 | **本期正式接入**(业务闭环见集成流程梳理:高风险客商签约必走;B103 结果回传) | **`POST /hcs/contract/spec-apply`(新增)+ `POST /hcs/callback/spec-result`(B103 接收,新增)** | 合同详情"发起特批"、特批状态展示、回传后自动恢复推送 | + +--- + +## 3. 后端补充端点设计(tms-api) + +全部挂在 `HcsSyncController`(`/hcs` 前缀,与既有端点同文件),复用 `HcsMasterDataSupport`、`HcsAttachmentSupport`、`IHcsClient`,不新建 Support。 + +### 3.1 主数据查询(走 Redis 缓存,未命中自动拉取) + +``` +GET /hcs/master-data/template +返回 R>:直接序列化 +``` + +仅模板一个端点;币种/分类已在 `HcsMasterDataSupport` 内部缓存供推送映射,不暴露前端。 + +### 3.2 附件代理 + +``` +POST /hcs/attachment/upload (multipart:fdFile + bizType 可选) + → 服务端包装 fdLoginName=当前登录名、fdAnonymous=false,调 C103-2 + → 返回 R<{ fdId }>;同时写 HcsAttachmentSupport 的 Redis 映射缓存(attachmentKey → fdId), + 使后续 C103-1 推送链路命中缓存不再重传 + → 文件限制与 TMS OSS 现行一致:pdf/bmp/jpeg/png/jpg/doc/docx/ppt/pptx/xlsx/xls/eml/msg/zip,≤50M + +GET /hcs/attachment/download?fdId=... + → 调 C103-5,文件流透传(Content-Disposition attachment,文件名从附件元数据或 fdId 兜底) + → axios 需 responseType: 'blob' +``` + +### 3.3 客商风险查询(C101-1) + +``` +GET /hcs/risk?customerCode=... + → 调 IHcsClient.relativeRisk(C101-1) + → 返回 R:{ fdId, fdNumber, fdBlackStatus, fdRiskLevel } + → 结果写 blade_hcs_check_log(biz_type=risk,留痕与合同推送链路同表) +``` + +> 前端仅用于**展示与提示**,不作为安全边界:强制校验仍在合同推送链路(`risk.block-blacklist`)与付款提交事务内。 + +### 3.4 合同发起特批(C103-8)+ 结果回传(B103)——本期正式接入 + +**业务闭环**(依据《TMS业财融合-集成流程图文字版与业务流程梳理.md》§3.3/§8): + +``` +合同审批通过 → C101-1 查甲乙方风险 + ├─ 非高风险 → 常规链路:C103-2 附件转传 → C103-1 推送(现状不变) + └─ 高风险 → 合同链路挂起(hcs_spec_status=need) + → TMS 内部特批审批(按原审批流) + → 前端补特批申请材料 + 内部审批流程附件 → C103-8 发起特批(hcs_spec_status=processing) + → 合同系统集团审批(流程在合同系统预配置) + → B103 特批结果回传 TMS(hcs_spec_status=approved/rejected) + → approved → 自动恢复 C103-1 推送 → 完成签订;rejected → 前端提示,重新发起 +``` + +**数据模型**:`blade_contract_manage` 追加特批字段组(照付款申请 `hcs_spec_*` 惯例),SQL 落 `doc/sql/transport/`: + +```sql +hcs_spec_fd_id varchar(64) COMMENT '合规特批流程ID(C103-8 返回)', +hcs_spec_status varchar(16) DEFAULT 'none' COMMENT '特批状态 none/need/processing/approved/rejected', +hcs_spec_time datetime COMMENT '最近特批动作时间', +hcs_spec_msg varchar(512) COMMENT '特批结果/失败原因', +hcs_spec_files_json text COMMENT '特批申请材料+内部审批附件(含C103-2 fdId)' +``` + +**端点设计**: + +``` +POST /hcs/contract/spec-apply (body:{ contractId, attachments: [{fdId, name}] }) + 校验:合同审批通过 && hcs_spec_status ∈ {need, rejected} && 特批材料非空 + 组装报文调 C103-8(报文模式预估照 C106-3:fd_contract_id=hcs_fd_id、fd_contract_no、 + fd_doc_subject="合同特批-"+contractNo、特批附件 mechanisms.attachment 带 fdBindType=ADD) + → 返回 data.fdId → hcs_spec_fd_id,状态置 processing;失败置 need 并记 hcs_spec_msg + → C103-8 v4 未含报文,联调前按上述预估实现并加 TODO 标注(§6-U1) + +POST /hcs/callback/spec-result (B103 合规→TMS 回调接收端点) + 公开端点(@PreAuth(AuthConstant.PERMIT_ALL) + 报文验签字段待合规方口径,先记录原文留痕) + body:{ 合同标识/特批流程fdId, 审批结果, 审批意见 } ← 字段名待 B103 报文(§6-U1) + 处理:定位合同 → 回写 hcs_spec_status=approved/rejected + hcs_spec_msg + → approved 时把合同 hcs_sync_status 重置 unsynced,由既有扫描任务自动走 C103-1(零新增链路) + → B103 报文未到位前端点先落库留痕不置状态,联调时接线 +``` + +**状态展示与操作权限**: + +| hcs_spec_status | 前端表现 | +|---|---| +| none | 无特批相关 UI | +| need(高风险待特批) | 合同详情警告条 +【发起特批】按钮可用;推送合规按钮禁用并提示"高风险客商,需先完成特批" | +| processing | "特批中"标签(橙);发起按钮禁用 | +| approved | "特批通过"标签(绿);推送恢复 | +| rejected | "特批驳回"标签(红)+ 驳回原因;可重新发起 | + +### 3.5 权限 + +`/hcs/**` 补 `@PreAuth`(菜单权限码,随菜单 SQL 一起落 `doc/sql/transport/`): + +| 权限码 | 控制点 | +|---|---| +| `hcs_sync_push` | 推送合规/重推按钮 | +| `hcs_sync_status` | 合规状态列/筛选(登录可见即可时不建) | +| `hcs_recon` | 合规对账弹窗 | +| `hcs_attachment` | 上传到合同系统 / 合规附件下载 | +| `hcs_master_data` | 主数据/映射查看页 | +| `hcs_spec_apply` | 发起特批按钮 | + +--- + +## 4. 前端设计(tms-erp-web) + +### 4.1 新增 API 模块 `src/api/business/hcs.js` + +集中一个文件,不拆散到各业务 api(对齐 `api/payment/paymentApplication.js` 的超时覆盖惯例): + +```js +import request from '@/axios'; + +const baseUrl = '/blade-transport/hcs'; + +// 合规推送涉及 HCS 远程多次调用(风险校验+附件转传+起草),可达 20s+,覆盖全局 10s 超时 +export const repush = (bizType, id) => + request({ url: `${baseUrl}/sync/${bizType}/${id}`, method: 'post', timeout: 60000 }); + +export const getTemplates = () => + request({ url: `${baseUrl}/master-data/template`, method: 'get' }); + +export const getRisk = customerCode => + request({ url: `${baseUrl}/risk`, method: 'get', params: { customerCode } }); + +export const uploadAttachment = formData => + request({ url: `${baseUrl}/attachment/upload`, method: 'post', data: formData, timeout: 60000 }); + +export const downloadAttachment = fdId => + request({ url: `${baseUrl}/attachment/download`, method: 'get', params: { fdId }, responseType: 'blob' }); + +export const reconProject = projectCode => + request({ url: `${baseUrl}/recon/project`, method: 'get', params: { projectCode } }); +export const reconContract = contractNo => + request({ url: `${baseUrl}/recon/contract`, method: 'get', params: { contractNo } }); +export const reconContractDetail = params => + request({ url: `${baseUrl}/recon/contract/detail`, method: 'get', params }); + +export const applyContractSpec = data => + request({ url: `${baseUrl}/contract/spec-apply`, method: 'post', data }); +``` + +### 4.2 结算币种(C103-12)——前端不改 + +前端"结算币种"下拉继续用本地字典 `currency_type`(`loadSettlementDictionaries()` 现状不动),存储值、编辑回显、下游运单/结算取值全部维持现状。C103-12 币种列表仅在后端推送 C103-1 时经 `currency-name-mapping`(`RMB→人民币`)映射,已实现,无前端工作。 + +### 4.3 合同模板(C103-6) + +- **表单"合同类别"保留本地字典 `contractCategory`**:它是 TMS 业务主数据,推送侧靠 Nacos `template-mapping` 转模板ID。C103-7 已移出前端范围(2026-10-09 确认)。 +- 新增**管理端"合规主数据"查看页**(`views/business/hcs-master-data.vue`,模板列表 + "刷新缓存"按钮,`POST /hcs/master-data/refresh` 已有)。菜单挂系统管理或合规管理目录下。 + +### 4.4 客商风险等级(C101-1) + +合同表单(新增/变更)选择甲方/乙方后: + +- 调 `getRisk(customerCode)`,在甲乙方字段旁展示风险标签:无风险/低/中/高 + 黑名单红标(`fdBlackStatus === 'yes'`)。 +- 高风险或黑名单时,表单顶部警告条:"该客商为高风险/黑名单客商,按集团规定不可签约,如需签约请发起特批",并提供【发起特批】按钮(走 §4.7)。 +- 项目立项表单不做强制校验(业务流程:风险校验在合同签订与付款环节),立项页不加。 +- 失败静默(HCS 不可达不阻塞填写,强校验在后端推送/付款链路)。 + +### 4.5 附件上传到合同系统(C103-2) + +- **表单附件流程不变**:仍传 TMS OSS,推送时后端自动转传(已实现,含 7 天 fdId 映射缓存)。 +- 新增**手动转传入口**:合同详情页附件区(`contract-attachment-section.vue`)加"上传到合同系统"操作(仅已审批通过的合同显示): + 1. 选中附件行 → `uploadAttachment(formData)`(前端从 OSS url 拉文件或直接复用原始 File); + 2. 返回 `fdId` 写入附件行 `hcsFdId` 字段(后端 `update-attachments` 已有,行结构加一字段即可); + 3. 已转传的行显示"已上传合规"标记。 +- 特批材料(C103-8 占位)也走此端点先取得 fdId。 + +### 4.6 合同附件下载(C103-5) + +- 对账弹窗(§4.7)中展示 HCS 侧附件列表(来自 C103-4 详情),点击下载调 `downloadAttachment(fdId)`,blob 落地文件。 +- `contract-attachment-section.vue` 的行内"下载"仍走 TMS OSS url,不改。 + +### 4.7 列表页合规状态 + 推送/对账(照"同步金蝶"模式) + +`project-apply.vue` 与 `contract-manage.vue` 各加: + +1. **状态列**:`合规状态`(prop `hcsSyncStatus`)——`unsynced 待推送`(灰)/`synced 已推送`(绿)/`failed 推送失败`(红);失败时 tooltip 显示 `hcsSyncMsg`;已推送的可附 `hcsFdId`。 + - 列配置加在 `src/option/business/project-apply.js`、`contract-manage.js`(或页面内 column 定义,与两页现状一致)。 + - 筛选区加"合规状态"下拉(unsynced/synced/failed,复用 settlement 模块 kingdeeSyncStatusOptions 的写法)。 +2. **操作按钮**:行操作"推送合规"(`repush(bizType, id)`,成功后刷新行;按钮文案照 customer-archive 的"推送中…"防重态);权限码 `hcs_sync_push`。 +3. **对账弹窗**:行操作/工具栏"合规对账"——立项按 `projectCode` 调 `reconProject`,合同按 `contractNo` 调 `reconContract`;弹窗内 JSON 树/描述列表展示 HCS 返回,附"查看合规详情"(C103-4,合同页专属)与 HCS 附件下载(§4.6)。权限码 `hcs_recon`。 + +### 4.8 合同发起特批(C103-8,本期接入) + +- **入口**:合同详情页【发起特批】按钮,仅 `hcsSpecStatus ∈ {need, rejected}` 时可用;权限码 `hcs_spec_apply`。 +- **交互**:弹窗选择/确认特批申请材料(先经 §4.5 `uploadAttachment` 取 fdId,弹窗内表格展示已传材料,含内部审批流程附件)→ 确认调 `applyContractSpec({ contractId, attachments })` → 成功后详情刷新为"特批中"。 +- **状态展示**:合同列表与详情按 §3.4 状态表展示特批标签(need/processing/approved/rejected);`need` 时列表"推送合规"按钮禁用并 tooltip 提示。 +- **结果回传**:B103 回调由后端接收并自动恢复推送,前端无需轮询;详情页打开时实时拉取最新状态即可,`rejected` 时展示 `hcsSpecMsg` 驳回原因。 + +--- + +## 5. 实施拆分 + +| 阶段 | tms-api | tms-erp-web | 依赖 | +|---|---|---|---| +| F1 基础 | `GET /hcs/master-data/template` + `@PreAuth`/菜单 SQL | `api/business/hcs.js`;管理端主数据页 | 无 | +| F2 状态与推送 | 无(端点已有) | 两列表页:状态列/筛选 + 推送合规按钮 | F1(hcs.js) | +| F3 对账与附件 | `/hcs/risk`、`/hcs/attachment/upload`、`/hcs/attachment/download` | 对账弹窗 + 合规附件下载 + 手动转传 + 附件行 `hcsFdId` | F2 | +| F4 特批 | 合同特批字段组 SQL;`POST /hcs/contract/spec-apply` + `POST /hcs/callback/spec-result`(B103);合同推送链路加 need 挂起判断 | 发起特批弹窗 + 特批状态标签 + 按钮联动 | C103-8/B103 报文(§6-U1,可先按预估模式实现联调替换) | + +--- + +## 6. 未决事项 + +| # | 事项 | 影响 | 当前处理 | +|---|---|---|---| +| U1 | **C103-8 / B103 报文 v4 未提供**(业务闭环已在集成流程梳理中明确) | 特批发起与结果回传的报文字段 | 按 C106-3 模式预估实现 + 回调端点先落库留痕;联调拿到报文后替换(代码已留 TODO) | +| U2 | **C103-5 返回流是否带文件名**(v4 仅写"文件流") | 下载文件命名 | 后端以附件元数据/`fdId` 兜底命名,联调校准 | +| U3 | 合规状态列/菜单权限是否需要按租户差异化 | 菜单 SQL | 先按统一菜单落地 | diff --git a/doc/sql/transport/blade_hcs_contract_spec_fields_20261009.sql b/doc/sql/transport/blade_hcs_contract_spec_fields_20261009.sql new file mode 100644 index 00000000..a7d0fc9b --- /dev/null +++ b/doc/sql/transport/blade_hcs_contract_spec_fields_20261009.sql @@ -0,0 +1,41 @@ +-- 桂物合同合规系统(HCS)接入:合同特批字段组(C103-8 发起特批 / B103 结果回传,前端接入设计 §3.4)。 +-- blade_contract_manage 追加特批流程ID/状态/时间/原因/材料字段;可重复执行。 +SET @db_name = DATABASE(); + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_contract_manage' AND column_name = 'hcs_spec_fd_id'), + 'SELECT 1', + 'ALTER TABLE blade_contract_manage ADD COLUMN hcs_spec_fd_id varchar(64) DEFAULT NULL COMMENT ''合规特批流程ID(C103-8 返回)''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_contract_manage' AND column_name = 'hcs_spec_status'), + 'SELECT 1', + 'ALTER TABLE blade_contract_manage ADD COLUMN hcs_spec_status varchar(16) DEFAULT ''none'' COMMENT ''合规特批状态 none/need/processing/approved/rejected''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_contract_manage' AND column_name = 'hcs_spec_time'), + 'SELECT 1', + 'ALTER TABLE blade_contract_manage ADD COLUMN hcs_spec_time datetime DEFAULT NULL COMMENT ''合规最近特批动作时间''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_contract_manage' AND column_name = 'hcs_spec_msg'), + 'SELECT 1', + 'ALTER TABLE blade_contract_manage ADD COLUMN hcs_spec_msg varchar(512) DEFAULT NULL COMMENT ''合规特批结果/发起失败原因''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = @db_name AND table_name = 'blade_contract_manage' AND column_name = 'hcs_spec_files_json'), + 'SELECT 1', + 'ALTER TABLE blade_contract_manage ADD COLUMN hcs_spec_files_json text COMMENT ''特批申请材料+内部审批附件JSON(含C103-2 合规附件ID)''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 存量合同的特批状态初始化为 none(新增列带 DEFAULT 'none' 已覆盖;此处兜底历史执行场景) +UPDATE blade_contract_manage SET hcs_spec_status = 'none' WHERE hcs_spec_status IS NULL AND is_deleted = 0; diff --git a/doc/sql/transport/blade_hcs_menu_20261009.sql b/doc/sql/transport/blade_hcs_menu_20261009.sql new file mode 100644 index 00000000..bebc8238 --- /dev/null +++ b/doc/sql/transport/blade_hcs_menu_20261009.sql @@ -0,0 +1,11 @@ +-- 桂物合同合规系统(HCS)前端接入:合规主数据菜单(前端接入设计 §4.3,照 AGENTS §15 菜单 SQL 规范)。 +-- 合规主数据页(模板列表 + 刷新缓存);字符集 utf8mb4_general_ci。ID 为雪花段占位,如与现有数据冲突请调整。 +-- 注意:/hcs/** 端点当前登录即可访问(照 HcsSyncController 现状),菜单落地后可按需补 @PreAuth(menu) 注解。 + +DELETE FROM blade_menu WHERE code IN ('hcs_master_data', 'hcs_master_data_refresh'); + +INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) +VALUES (2075432100100000001, 1164733399668962201, 'hcs_master_data', '合规主数据', 'hcs_master_data', '/business/hcs-master-data', 'iconfont icon-caidan', 40, 1, 0, 1, '', '', 0); + +INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) +VALUES (2075432100100000002, 2075432100100000001, 'hcs_master_data_refresh', '刷新缓存', 'hcs_master_data_refresh', '', '', 1, 2, 0, 1, '', '', 0);