From d6561dda17ccd2f4318501d90dbe2be15620a752 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, 27 Sep 2026 23:47:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20HCS=E4=BB=98=E6=AC=BE=E9=93=BE=E6=8E=A8?= =?UTF-8?q?=E9=80=81=EF=BC=88C106-3/4=E7=BB=93=E7=AE=97=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=89=B9=E6=89=B9/C108-1/2=E5=B1=A5=E7=BA=A6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?/=E6=89=AB=E6=8F=8F=E7=BC=96=E6=8E=92=E4=B8=8E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E7=82=B9/=E7=89=B9=E6=89=B9=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=BA=BA=E5=B7=A5=E7=A1=AE=E8=AE=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/HcsSyncController.java | 26 +- .../support/hcs/HcsPaymentSyncSupport.java | 156 ++++++++++++ .../support/hcs/HcsPerfRecordSyncSupport.java | 230 ++++++++++++++++++ .../support/hcs/HcsSpecPaySyncSupport.java | 229 +++++++++++++++++ .../transport/task/HcsPushTask.java | 43 +++- .../springblade/hcs/config/HcsProperties.java | 12 + doc/nacos/third-party-api.yaml | 4 + 7 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java 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 8392512f..f10871d1 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 @@ -34,11 +34,13 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.tool.api.R; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsPaymentSyncSupport; import org.springblade.transport.support.hcs.HcsMasterDataSupport; import org.springblade.transport.support.hcs.HcsProjectSyncSupport; import org.springblade.transport.task.HcsPushTask; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -61,44 +63,58 @@ public class HcsSyncController { private final HcsPushTask hcsPushTask; private final HcsProjectSyncSupport hcsProjectSyncSupport; private final HcsContractSyncSupport hcsContractSyncSupport; + private final HcsPaymentSyncSupport hcsPaymentSyncSupport; private final HcsMasterDataSupport hcsMasterDataSupport; /** - * 单条重推(bizType:project=项目立项 / contract=合同) + * 单条重推(bizType:project=项目立项 / contract=合同 / payment=付款申请) */ @PostMapping("/sync/{bizType}/{id}") @ApiOperationSupport(order = 1) - @Operation(summary = "单条推送/重推合规", description = "重置待同步状态后立即推送,bizType:project=项目立项、contract=合同") + @Operation(summary = "单条推送/重推合规", description = "重置待同步状态后立即推送,bizType:project=项目立项、contract=合同、payment=付款申请") public R repush(@Parameter(description = "业务类型", required = true) @PathVariable String bizType, @Parameter(description = "单据ID", required = true) @PathVariable Long id) { String result = switch (bizType) { case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsProjectSyncSupport.repushProject(id); case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsContractSyncSupport.repushContract(id); + case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPaymentSyncSupport.repushPayment(id); default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType); }; return R.data(result); } /** - * 批量推送(bizType:project=项目立项 / contract=合同;复用定时扫描逻辑,单轮批量上限 push.batch-size) + * 批量推送(bizType:project=项目立项 / contract=合同 / payment=付款申请;复用定时扫描逻辑,单轮批量上限 push.batch-size) */ @PostMapping("/sync/{bizType}") @ApiOperationSupport(order = 2) - @Operation(summary = "批量推送合规", description = "扫描待同步/待补偿单据推送一轮,bizType:project=项目立项、contract=合同") + @Operation(summary = "批量推送合规", description = "扫描待同步/待补偿单据推送一轮,bizType:project=项目立项、contract=合同、payment=付款申请") public R pushBatch(@Parameter(description = "业务类型", required = true) @PathVariable String bizType) { String result = switch (bizType) { case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsPushTask.pushUnsyncedProjects(); case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsPushTask.pushUnsyncedContracts(); + case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPushTask.pushUnsyncedPayments(); default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType); }; return R.data(result); } + /** + * 特批结果人工确认(§9-U1 过渡方案:v4 无结果查询/回调接口) + */ + @PostMapping("/spec-pay/confirm") + @ApiOperationSupport(order = 3) + @Operation(summary = "特批结果人工确认", description = "传入付款申请ID与结果(approved/rejected),仅特批中(processing)允许确认") + public R confirmSpecResult(@Parameter(description = "付款申请ID", required = true) @RequestParam Long id, + @Parameter(description = "确认结果 approved/rejected", required = true) @RequestParam String result) { + return R.data(hcsPaymentSyncSupport.confirmSpecResult(id, result)); + } + /** * 刷新主数据缓存(C103-6 模板 / C103-7 分类 / C103-12 币种) */ @PostMapping("/master-data/refresh") - @ApiOperationSupport(order = 3) + @ApiOperationSupport(order = 4) @Operation(summary = "刷新HCS主数据缓存", description = "全量重拉模板/分类/币种列表并缓存,同时校验模板映射配置") public R refreshMasterData() { return R.data(hcsMasterDataSupport.refreshAll()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java new file mode 100644 index 00000000..8fbb1062 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPaymentSyncSupport.java @@ -0,0 +1,156 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support.hcs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * HCS 付款链推送编排支撑组件(结算付款特批 C106-3/4 + 履约记录 C108-1/2,设计文档 §5.0/§5.5/§5.6) + *

+ * 编排顺序:特批(额度超额标记 need 时发起)→ 履约记录(审批通过新建 / 实际付款推进更新)。 + * 特批流程ID 与履约记录ID 在各自 Support 内即时持久化;整体同步状态(hcs_sync_status)由本组件统一回写。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsPaymentSyncSupport { + + /** + * 审批通过状态集合 + */ + private static final List APPROVED_STATUSES = List.of("approved"); + + private final PaymentApplicationMapper paymentApplicationMapper; + private final HcsSpecPaySyncSupport hcsSpecPaySyncSupport; + private final HcsPerfRecordSyncSupport hcsPerfRecordSyncSupport; + + /** + * 推送付款申请到 HCS:特批(如需)→ 履约记录(新建/更新)→ 回填整体同步状态 + * + * @param payment 付款申请单(approvalStatus=approved) + * @return 推送结果描述 + */ + public String pushPayment(PaymentApplication payment) { + StringBuilder result = new StringBuilder(); + // ① 结算付款特批:提交时额度超额(hcs_spec_status=need)且未发起时发起 C106-3 + if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) { + result.append(hcsSpecPaySyncSupport.pushSpecPay(payment)); + } + // ② 履约记录:审批通过新建 C108-1 / 实际付款推进更新 C108-2 + if (result.length() > 0) { + result.append(";"); + } + result.append(hcsPerfRecordSyncSupport.pushPerfRecord(payment)); + markSynced(payment, result.toString()); + return result.toString(); + } + + /** + * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口) + * + * @param id 付款申请单ID + * @return 推送结果描述 + */ + public String repushPayment(Long id) { + PaymentApplication payment = paymentApplicationMapper.selectById(id); + if (payment == null) { + throw new ServiceException("付款申请单不存在:" + id); + } + if (!APPROVED_STATUSES.contains(payment.getApprovalStatus())) { + throw new ServiceException("仅审批通过的付款申请可推送合规(当前状态:" + payment.getApprovalStatus() + ")"); + } + payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); + payment.setHcsRetryCount(0); + paymentApplicationMapper.updateById(payment); + try { + return pushPayment(payment); + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + markFailed(payment, message); + throw new ServiceException("HCS付款申请推送失败:" + message); + } + } + + /** + * 特批结果人工确认(§9-U1 过渡方案:v4 无结果查询/回调接口) + * + * @param id 付款申请单ID + * @param result confirmed approved / rejected + * @return 确认结果描述 + */ + public String confirmSpecResult(Long id, String result) { + PaymentApplication payment = paymentApplicationMapper.selectById(id); + if (payment == 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(payment.getHcsSpecStatus())) { + throw new ServiceException("仅特批中的付款申请允许确认结果(当前状态:" + payment.getHcsSpecStatus() + ")"); + } + payment.setHcsSpecStatus(result); + paymentApplicationMapper.updateById(payment); + log.info("HCS特批结果人工确认:paymentNo={},specFdId={},结果={}", payment.getPaymentNo(), payment.getHcsSpecFdId(), result); + return "特批结果已确认:" + result; + } + + /** + * 回填整体同步状态置 synced + */ + public void markSynced(PaymentApplication payment, String message) { + payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED); + payment.setHcsRetryCount(0); + payment.setHcsSyncTime(LocalDateTime.now()); + payment.setHcsSyncMsg(message); + paymentApplicationMapper.updateById(payment); + log.info("HCS付款申请推送成功:paymentNo={},{}", payment.getPaymentNo(), message); + } + + /** + * 推送失败回写:failed + 重试次数+1 + 失败原因 + */ + public void markFailed(PaymentApplication payment, String message) { + payment.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED); + payment.setHcsRetryCount(payment.getHcsRetryCount() == null ? 1 : payment.getHcsRetryCount() + 1); + payment.setHcsSyncTime(LocalDateTime.now()); + payment.setHcsSyncMsg(message); + paymentApplicationMapper.updateById(payment); + log.error("HCS付款申请推送失败:paymentNo={},retryCount={},{}", payment.getPaymentNo(), payment.getHcsRetryCount(), message); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java new file mode 100644 index 00000000..1019a314 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsPerfRecordSyncSupport.java @@ -0,0 +1,230 @@ +/** + * 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 com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.hcs.dto.HcsAttachmentRefDTO; +import org.springblade.hcs.dto.HcsMechanismsDTO; +import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO; +import org.springblade.hcs.dto.HcsPerfRecordPropsDTO; +import org.springblade.hcs.dto.HcsPerfRecordSaveDTO; +import org.springblade.hcs.dto.HcsResult; +import org.springblade.hcs.dto.HcsFormSaveVO; +import org.springblade.hcs.dto.HcsUserRefVO; +import org.springblade.hcs.exception.HcsException; +import org.springblade.hcs.feign.IHcsClient; +import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.User; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.HcsContractExeStageMapper; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.HcsContractExeStage; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +/** + * HCS 履约记录同步支撑组件(C108-1 新建 / C108-2 更新,设计文档 §5.6,D11) + *

+ * 付款申请为主线:审批通过 → C108-1 新增(返回 fdId 回填 hcs_perf_fd_id); + * 实际付款推进(paidAmount 变化置待同步)→ C108-2 更新(formData.fdId=hcs_perf_fd_id 必填,响应无 data)。 + * 关联履约阶段:按合同+付款类型匹配已同步的履约规则行(付款/收款执行与计划阶段对应), + * 无关联则不传 fd_id(§5.6)。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsPerfRecordSyncSupport { + + /** + * 履约记录附件字段标识 + */ + private static final String PERF_ATTACH_ENTITY_KEY = "fd_plan_att"; + + private final IHcsClient hcsClient; + private final HcsProperties hcsProperties; + private final ContractManageMapper contractManageMapper; + private final HcsContractExeStageMapper hcsContractExeStageMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + private final HcsAttachmentSupport hcsAttachmentSupport; + + /** + * 推送履约记录:无记录ID 走 C108-1 新增,有记录ID 走 C108-2 更新;记录ID 立即持久化 + * + * @param payment 付款申请单(approvalStatus=approved) + * @return 推送结果描述 + */ + public String pushPerfRecord(PaymentApplication payment) { + String operatorLoginName = resolveLoginName(payment); + if (StringUtil.isBlank(payment.getHcsPerfFdId())) { + HcsPerfRecordSaveDTO param = buildAddParam(payment, operatorLoginName); + log.info("HCS履约记录新建开始:paymentNo={}", payment.getPaymentNo()); + HcsResult result = hcsClient.perfRecordAdd(param); + if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) { + log.error("HCS履约记录新建失败响应:{},paymentNo={}", result == null ? "无响应" : result.getMsg(), payment.getPaymentNo()); + throw new HcsException("C108-1 履约记录新建失败:" + (result == null ? "响应为空" : result.getMsg())); + } + // 履约记录ID 立即持久化(调用方必须保存) + payment.setHcsPerfFdId(result.getData().getFdId()); + paymentApplicationMapper.updateById(payment); + log.info("HCS履约记录新建成功:paymentNo={},perfFdId={}", payment.getPaymentNo(), payment.getHcsPerfFdId()); + return "C108-1 履约记录新建成功"; + } + // C108-2 更新:formData.fdId 必填,响应无 data,success=true 即成功 + HcsPerfRecordSaveDTO param = buildUpdateParam(payment, operatorLoginName); + log.info("HCS履约记录更新开始:paymentNo={},perfFdId={}", payment.getPaymentNo(), payment.getHcsPerfFdId()); + HcsResult result = hcsClient.perfRecordUpdate(param); + if (result == null || !result.isSuccess()) { + log.error("HCS履约记录更新失败响应:{},paymentNo={}", result == null ? "无响应" : result.getMsg(), payment.getPaymentNo()); + throw new HcsException("C108-2 履约记录更新失败:" + (result == null ? "响应为空" : result.getMsg())); + } + return "C108-2 履约记录更新成功"; + } + + /** + * C108-1 新建入参(字段标注照 §3.4 规范,映射见设计文档 §5.6) + */ + private HcsPerfRecordSaveDTO buildAddParam(PaymentApplication payment, String operatorLoginName) { + HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO(); + // 【必填】当前操作人 {fdLoginName}(付款申请创建人,§9-U2) + param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName)); + param.setFormData(buildFormData(payment)); + // 附件挂载:attachmentsJson 转传,fdEntityKey=fd_plan_att + param.setMechanisms(buildMechanisms(payment, operatorLoginName)); + return param; + } + + /** + * C108-2 更新入参:formData.fdId 必填,传实际金额与合同ID + */ + private HcsPerfRecordSaveDTO buildUpdateParam(PaymentApplication payment, String operatorLoginName) { + HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO(); + param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName)); + HcsPerfRecordFormDataDTO formData = buildFormData(payment); + // 【必填】履约记录ID(C108-1 新建返回) + formData.setFdId(payment.getHcsPerfFdId()); + param.setFormData(formData); + return param; + } + + /** + * 履约记录 formData(新建/更新共用) + */ + private HcsPerfRecordFormDataDTO buildFormData(PaymentApplication payment) { + ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId()); + HcsContractExeStage stageRow = findPaymentStageRow(payment); + HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO(); + HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO(); + // 申请金额:appliedAmount(接口按字符串传值) + props.setFdApplyAmount(payment.getAppliedAmount() == null ? null : payment.getAppliedAmount().toPlainString()); + // 实际金额:paidAmount(实际付款推进后更新) + props.setFdFactAmount(payment.getPaidAmount() == null ? null : payment.getPaidAmount().toPlainString()); + // 申请编号:付款申请单号(接口文档 §8.3 字段) + props.setFdApplyNo(payment.getPaymentNo()); + // 计划日期:applyDate(毫秒时间戳) + props.setFdPlanDate(toMillis(payment.getApplyDate())); + // 计划类型:paymentType(原文拼写 fd_paln_type 必须沿用) + props.setFdPalnType(payment.getPaymentType()); + // 性质:款项性质取关联履约规则行的款项性质(无关联不传) + props.setFdProperty(stageRow == null ? null : stageRow.getFundNature()); + // 来源履约计划项ID:关联履约阶段 hcs_stage_fd_id(无关联则不传,§5.6) + props.setFdId(stageRow == null ? null : stageRow.getHcsStageFdId()); + // 合同ID:HCS 合同ID(合同未推送时不传) + props.setFdContractId(contractManage == null ? null : contractManage.getHcsFdId()); + // 表单模板ID:配置 perf-record.xform-id(环境值,§9-U9,未配置不传) + props.setFdXformId(StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId()); + formData.setDynamicProps(props); + return formData; + } + + /** + * 履约记录附件挂载(无附件或开关关闭返回 null) + */ + private HcsMechanismsDTO buildMechanisms(PaymentApplication payment, String operatorLoginName) { + if (!hcsProperties.getAttachment().isTransferEnabled() || StringUtil.isBlank(payment.getAttachmentsJson())) { + return null; + } + List attachmentFdIds = hcsAttachmentSupport.transferAttachments(payment.getAttachmentsJson(), operatorLoginName); + if (attachmentFdIds.isEmpty()) { + return null; + } + HcsMechanismsDTO mechanisms = new HcsMechanismsDTO(); + mechanisms.setAttachment(attachmentFdIds.stream().map(fdId -> { + HcsAttachmentRefDTO attachment = new HcsAttachmentRefDTO(); + // 附件ID(C103-2 转传返回) + attachment.setFdId(fdId); + // 履约记录附件字段标识 + attachment.setFdEntityKey(PERF_ATTACH_ENTITY_KEY); + return attachment; + }).toList()); + return mechanisms; + } + + /** + * 匹配合同的付款履约规则行(perform_type=payment 且已同步,取第一条) + */ + private HcsContractExeStage findPaymentStageRow(PaymentApplication payment) { + if (payment.getContractId() == null) { + return null; + } + return hcsContractExeStageMapper.selectOne(Wrappers.lambdaQuery() + .eq(HcsContractExeStage::getContractId, payment.getContractId()) + .eq(HcsContractExeStage::getPerformType, "payment") + .isNotNull(HcsContractExeStage::getHcsStageFdId) + .last("limit 1")); + } + + /** + * 操作人登录名(付款申请创建人,§9-U2) + */ + private String resolveLoginName(PaymentApplication payment) { + if (payment.getCreateUser() == null) { + throw new HcsException("C108 履约记录推送失败,付款申请创建人未维护(paymentNo=" + payment.getPaymentNo() + ")"); + } + User user = UserCache.getUser(payment.getCreateUser()); + if (user == null || StringUtil.isBlank(user.getAccount())) { + throw new HcsException("C108 履约记录推送失败,创建人登录名解析不到(createUser=" + payment.getCreateUser() + ")"); + } + return user.getAccount(); + } + + /** + * LocalDate → 毫秒时间戳 + */ + private Long toMillis(LocalDate localDate) { + return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java new file mode 100644 index 00000000..990490e6 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsSpecPaySyncSupport.java @@ -0,0 +1,229 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support.hcs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.hcs.config.HcsProperties; +import org.springblade.hcs.dto.HcsAttachmentRefDTO; +import org.springblade.hcs.dto.HcsMechanismsDTO; +import org.springblade.hcs.dto.HcsResult; +import org.springblade.hcs.dto.HcsFormSaveVO; +import org.springblade.hcs.dto.HcsSpecPayFormDataDTO; +import org.springblade.hcs.dto.HcsSpecPayPropsDTO; +import org.springblade.hcs.dto.HcsSpecPaySaveDTO; +import org.springblade.hcs.exception.HcsException; +import org.springblade.hcs.feign.IHcsClient; +import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.system.pojo.entity.User; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * HCS 结算付款特批同步支撑组件(C106-3 发起 / C106-4 更新,设计文档 §5.5) + *

+ * 触发:付款申请审批通过且 hcs_spec_status=need(提交时额度超额标记)→ 扫描任务发起 C106-3, + * 返回 data.data.fdId(双层嵌套)→ hcs_spec_fd_id,状态置 processing; + * 单据变更且特批流程存在时走 C106-4(顶层 fdId=hcs_spec_fd_id,附件不带 fdBindType)。 + * 特批结果:v4 无结果查询/回调接口,approved/rejected 一期由页面人工确认(§9-U1)。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsSpecPaySyncSupport { + + /** + * 特批附件字段标识 + */ + private static final String SPEC_ATTACH_ENTITY_KEY = "fd_spc_approval_attach"; + + private final IHcsClient hcsClient; + private final HcsProperties hcsProperties; + private final ContractManageMapper contractManageMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + private final HcsAttachmentSupport hcsAttachmentSupport; + + /** + * 推送结算付款特批:未发起走 C106-3,已发起走 C106-4;流程ID 与状态立即持久化 + * + * @param payment 付款申请单(hcs_spec_status=need) + * @return 推送结果描述 + */ + public String pushSpecPay(PaymentApplication payment) { + String operatorLoginName = resolveLoginName(payment); + HcsSpecPaySaveDTO param = buildSpecPayParam(payment, operatorLoginName); + if (StringUtil.isBlank(payment.getHcsSpecFdId())) { + // C106-3 发起:返回双层嵌套 data.data.fdId + log.info("HCS结算付款特批发起开始:paymentNo={}", payment.getPaymentNo()); + HcsResult> result = hcsClient.addSpecApplyPay(param); + String specFdId = extractNestedFdId(payment, result); + payment.setHcsSpecFdId(specFdId); + payment.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_PROCESSING); + paymentApplicationMapper.updateById(payment); + log.info("HCS结算付款特批发起成功:paymentNo={},specFdId={}", payment.getPaymentNo(), specFdId); + return "C106-3 特批发起成功"; + } + // C106-4 更新:顶层 fdId 必填,内层无业务数据,以内层 success 判断 + log.info("HCS结算付款特批更新开始:paymentNo={},specFdId={}", payment.getPaymentNo(), payment.getHcsSpecFdId()); + HcsResult> result = hcsClient.updateSpecApplyPay(param); + if (result == null || !result.isSuccess()) { + log.error("HCS结算付款特批更新失败响应:{},paymentNo={}", result == null ? "无响应" : result.getMsg(), payment.getPaymentNo()); + throw new HcsException("C106-4 特批更新失败:" + (result == null ? "响应为空" : result.getMsg())); + } + return "C106-4 特批更新成功"; + } + + /** + * 组装 C106-3/C106-4 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.5) + */ + private HcsSpecPaySaveDTO buildSpecPayParam(PaymentApplication payment, String operatorLoginName) { + ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId()); + HcsSpecPaySaveDTO param = new HcsSpecPaySaveDTO(); + // 结算付款流程ID:发起不传,更新必填(C106-4 顶层 fdId) + param.setFdId(payment.getHcsSpecFdId()); + // 【必填】操作人登录名:付款申请创建人登录名(§9-U2) + param.setFdLoginName(operatorLoginName); + HcsSpecPayFormDataDTO formData = new HcsSpecPayFormDataDTO(); + HcsSpecPayPropsDTO props = new HcsSpecPayPropsDTO(); + // 【必填】合同:传合规合同ID(blade_contract_manage.hcs_fd_id,非名称) + props.setFdContractName(requireContractFdId(payment, contractManage)); + // 合同编号 + props.setFdContractNo(payment.getContractNo()); + // 单据编号:付款申请单号(来源付款单标识) + props.setFdDocNo(payment.getPaymentNo()); + // 单据主题:"结算付款特批-" + 付款单号 + props.setFdDocSubject("结算付款特批-" + payment.getPaymentNo()); + // 来源付款单ID:付款申请单号(§5.5 映射表) + props.setFdId(payment.getPaymentNo()); + // 付款金额:appliedAmount + props.setFdPayAmount(payment.getAppliedAmount()); + // 合计金额:settlementAmount + props.setFdTotalAmount(payment.getSettlementAmount() == null ? BigDecimal.ZERO : payment.getSettlementAmount()); + // 付款人 / 收款人 + props.setFdPayer(payment.getPayerName()); + props.setFdPayee(payment.getPayeeName()); + // 收款账户名 / 收款账号 / 开户行 + props.setFdReceiveAccountName(payment.getReceiptAccountName()); + props.setFdReceiveAccount(payment.getBankAccount()); + props.setFdBankName(payment.getBankName()); + // 结算币种:TMS 无币种字段,按 currency-name-mapping 默认值(人民币) + props.setFdSettleCurrency(hcsProperties.getContract().getCurrencyNameMapping().getOrDefault("RMB", "人民币")); + // 付款用途 / 付款性质 / 付款类型:字典值配置(§9-U3 待联调后配置) + props.setFdPaymentPurpose(hcsProperties.getSpecPay().getPaymentPurpose()); + props.setFdPaymentNature(hcsProperties.getSpecPay().getPaymentNature()); + props.setFdPaymentType(hcsProperties.getSpecPay().getPaymentType()); + // 事由 / 补充说明:remark + props.setFdReason(payment.getRemark()); + props.setFdSupplementNote(payment.getRemark()); + // 单据状态:配置 spec-pay.doc-status(示例 10) + props.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus()); + // 特批附件字段标识:固定 fd_spc_approval_attach + props.setFdSpcApprovalAttach(SPEC_ATTACH_ENTITY_KEY); + formData.setDynamicProps(props); + // 表单层单据主题 + formData.setFdDocSubject("结算付款特批-" + payment.getPaymentNo()); + // 表单层单据状态:配置 spec-pay.doc-status + formData.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus()); + param.setFormData(formData); + // 附件挂载:attachmentsJson 经 C103-2 转传,特批附件 fdEntityKey=fd_spc_approval_attach, + // 发起带 fdBindType=ADD,更新不带 + param.setMechanisms(buildMechanisms(payment, operatorLoginName)); + return param; + } + + /** + * 特批附件挂载(附件转传开关关闭或无附件时返回 null) + */ + private HcsMechanismsDTO buildMechanisms(PaymentApplication payment, String operatorLoginName) { + if (!hcsProperties.getAttachment().isTransferEnabled() || StringUtil.isBlank(payment.getAttachmentsJson())) { + return null; + } + List attachmentFdIds = hcsAttachmentSupport.transferAttachments(payment.getAttachmentsJson(), operatorLoginName); + if (attachmentFdIds.isEmpty()) { + return null; + } + HcsMechanismsDTO mechanisms = new HcsMechanismsDTO(); + boolean add = StringUtil.isBlank(payment.getHcsSpecFdId()); + mechanisms.setAttachment(attachmentFdIds.stream().map(fdId -> { + HcsAttachmentRefDTO attachment = new HcsAttachmentRefDTO(); + // 附件ID(C103-2 转传返回) + attachment.setFdId(fdId); + // 特批附件字段标识 + attachment.setFdEntityKey(SPEC_ATTACH_ENTITY_KEY); + // 绑定类型:仅发起传 ADD + attachment.setFdBindType(add ? "ADD" : null); + return attachment; + }).toList()); + return mechanisms; + } + + /** + * 解析 C106-3 双层嵌套返回 data.data.fdId + */ + private String extractNestedFdId(PaymentApplication payment, 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结算付款特批发起失败响应:{},paymentNo={}", result == null ? "无响应" : result.getMsg(), payment.getPaymentNo()); + throw new HcsException("C106-3 特批发起失败:" + (result == null ? "响应为空" : result.getMsg())); + } + return result.getData().getData().getFdId(); + } + + /** + * 合规合同ID(fd_contract_name 必填,合同未推送时失败提示) + */ + private String requireContractFdId(PaymentApplication payment, ContractManage contractManage) { + if (contractManage == null || StringUtil.isBlank(contractManage.getHcsFdId())) { + throw new HcsException("C106 特批推送失败,关联合同尚未推送合规(paymentNo=" + payment.getPaymentNo() + ")"); + } + return contractManage.getHcsFdId(); + } + + /** + * 操作人登录名(付款申请创建人,§9-U2) + */ + private String resolveLoginName(PaymentApplication payment) { + if (payment.getCreateUser() == null) { + throw new HcsException("C106 特批推送失败,付款申请创建人未维护(paymentNo=" + payment.getPaymentNo() + ")"); + } + User user = UserCache.getUser(payment.getCreateUser()); + if (user == null || StringUtil.isBlank(user.getAccount())) { + throw new HcsException("C106 特批推送失败,创建人登录名解析不到(createUser=" + payment.getCreateUser() + ")"); + } + return user.getAccount(); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java index 6d35899c..dc6fcbf5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java @@ -33,10 +33,13 @@ import org.springblade.core.tool.utils.StringUtil; import org.springblade.hcs.config.HcsProperties; import org.springblade.transport.constant.HcsSyncConstant; import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.ProjectApplyMapper; import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsPaymentSyncSupport; import org.springblade.transport.support.hcs.HcsProjectSyncSupport; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; @@ -51,9 +54,9 @@ import java.util.UUID; *

* 审批代码零埋点:扫描只认 approvalStatus=approved/change_approved 且 hcs_sync_status=unsynced/failed(重试未超限), * 与审批入口(/approve、MK 回调)无关;失败落 failed 由下轮扫描自动补偿,超限仅人工重推(D7)。 - * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 C103-9 行级同步)。 + * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 C103-9 行级同步)→ 付款特批/履约记录。 * 多实例部署时以 Redis 分布式锁防重扫(照既有 BladeRedis 用法,单实例部署行为与既有金蝶任务一致)。 - * 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts,供手动端点(HcsSyncController)复用。 + * 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts/pushUnsyncedPayments,供手动端点(HcsSyncController)复用。 *

* 调度配置(Nacos thirdParty.hcs.push.*):sync-enabled 总开关(默认关,灰度打开)、cron、batch-size、max-retry。 * @@ -77,8 +80,10 @@ public class HcsPushTask { private final HcsProperties hcsProperties; private final ProjectApplyMapper projectApplyMapper; private final ContractManageMapper contractManageMapper; + private final PaymentApplicationMapper paymentApplicationMapper; private final HcsProjectSyncSupport hcsProjectSyncSupport; private final HcsContractSyncSupport hcsContractSyncSupport; + private final HcsPaymentSyncSupport hcsPaymentSyncSupport; private final BladeRedis bladeRedis; /** @@ -104,9 +109,10 @@ public class HcsPushTask { return; } try { - // 任务顺序(设计文档 §5.0):项目 → 合同;两类互不影响 + // 任务顺序(设计文档 §5.0):项目 → 合同 → 付款;各类互不影响 log.info("定时推送HCS项目立项:{}", pushUnsyncedProjects()); log.info("定时推送HCS合同:{}", pushUnsyncedContracts()); + log.info("定时推送HCS付款链:{}", pushUnsyncedPayments()); } finally { if (lockHolder.equals(bladeRedis.get(HcsSyncConstant.PUSH_TASK_LOCK_KEY))) { bladeRedis.del(HcsSyncConstant.PUSH_TASK_LOCK_KEY); @@ -175,4 +181,35 @@ public class HcsPushTask { } return summary; } + + /** + * 扫描并推送待同步/待补偿的付款申请(C106 特批 + C108 履约记录),返回结果汇总 + * + * @return 汇总描述 + */ + public String pushUnsyncedPayments() { + int maxRetry = hcsProperties.getPush().getMaxRetry(); + List paymentList = paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .eq(PaymentApplication::getApprovalStatus, "approved") + .in(PaymentApplication::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED)) + .and(wrapper -> wrapper.isNull(PaymentApplication::getHcsRetryCount).or().lt(PaymentApplication::getHcsRetryCount, maxRetry)) + .last("limit " + hcsProperties.getPush().getBatchSize())); + int successCount = 0; + int failCount = 0; + for (PaymentApplication payment : paymentList) { + try { + hcsPaymentSyncSupport.pushPayment(payment); + successCount++; + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + hcsPaymentSyncSupport.markFailed(payment, message); + failCount++; + } + } + String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", paymentList.size(), successCount, failCount, maxRetry); + if (failCount > 0) { + log.warn("HCS付款链推送存在失败:{}", summary); + } + return summary; + } } diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java index 93f2fefb..3963b0d8 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java @@ -100,6 +100,10 @@ public class HcsProperties { * 结算付款特批(C106-3/4)配置 */ private SpecPay specPay = new SpecPay(); + /** + * 履约记录(C108-1/2)配置 + */ + private PerfRecord perfRecord = new PerfRecord(); /** * 开票登记(C111)配置 */ @@ -245,6 +249,14 @@ public class HcsProperties { private Map invoiceTypeMapping = new HashMap<>(); } + @Data + public static class PerfRecord { + /** + * 履约记录表单模板ID(fd_xform_id,环境相关值,§9-U9 待合规方提供) + */ + private String xformId = ""; + } + @Data public static class Attachment { /** diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index db3628d9..d25cd9ad 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -138,6 +138,10 @@ thirdParty: receipt-bill: # TMS 发票类型 -> HCS 收票发票类型字典值(§9-U3 待联调后配置) invoice-type-mapping: {} + # 履约记录(C108-1/2) + perf-record: + # 表单模板ID(fd_xform_id,环境相关值,§9-U9 待合规方提供) + xform-id: ${HCS_PERF_RECORD_XFORM_ID:} # 附件转传(C103-2) attachment: # 附件转传总开关