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
new file mode 100644
index 00000000..8392512f
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java
@@ -0,0 +1,106 @@
+/**
+ * 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.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
+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.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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * HCS 合规推送端点(设计文档 §5.0 手动入口)
+ *
+ * 前端列表页"推送合规/重推"按钮(照"同步金蝶"按钮模式):
+ * 单条重推重置 hcs_sync_status=unsynced 并清零重试计数后立即推送;批量端点复用定时扫描逻辑。
+ * 排查推送问题以 hcs_sync_status/hcs_sync_msg 为准,不以审批日志为准(ADR-0001)。
+ *
+ * @author Chill
+ */
+@Slf4j
+@RestController
+@AllArgsConstructor
+@RequestMapping("hcs")
+@Tag(name = "HCS合规推送", description = "HCS合规推送端点")
+public class HcsSyncController {
+
+ private final HcsPushTask hcsPushTask;
+ private final HcsProjectSyncSupport hcsProjectSyncSupport;
+ private final HcsContractSyncSupport hcsContractSyncSupport;
+ private final HcsMasterDataSupport hcsMasterDataSupport;
+
+ /**
+ * 单条重推(bizType:project=项目立项 / contract=合同)
+ */
+ @PostMapping("/sync/{bizType}/{id}")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "单条推送/重推合规", description = "重置待同步状态后立即推送,bizType:project=项目立项、contract=合同")
+ 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);
+ default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType);
+ };
+ return R.data(result);
+ }
+
+ /**
+ * 批量推送(bizType:project=项目立项 / contract=合同;复用定时扫描逻辑,单轮批量上限 push.batch-size)
+ */
+ @PostMapping("/sync/{bizType}")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "批量推送合规", description = "扫描待同步/待补偿单据推送一轮,bizType:project=项目立项、contract=合同")
+ 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();
+ default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType);
+ };
+ return R.data(result);
+ }
+
+ /**
+ * 刷新主数据缓存(C103-6 模板 / C103-7 分类 / C103-12 币种)
+ */
+ @PostMapping("/master-data/refresh")
+ @ApiOperationSupport(order = 3)
+ @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/HcsContractSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java
index 734f0e3d..4b06fcf8 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
@@ -28,6 +28,7 @@ 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.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.dto.HcsContractDraftDTO;
@@ -342,6 +343,32 @@ public class HcsContractSyncSupport {
return result.getData().getFdContractId();
}
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
+ *
+ * @param id 合同单ID
+ * @return 推送结果描述
+ */
+ public String repushContract(Long id) {
+ ContractManage contractManage = contractManageMapper.selectById(id);
+ if (contractManage == null) {
+ throw new ServiceException("合同单不存在:" + id);
+ }
+ if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的合同可推送合规(当前状态:" + contractManage.getApprovalStatus() + ")");
+ }
+ contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ contractManage.setHcsRetryCount(0);
+ contractManageMapper.updateById(contractManage);
+ try {
+ return pushContract(contractManage);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(contractManage, message);
+ throw new ServiceException("HCS合同推送失败:" + message);
+ }
+ }
+
/**
* 回填合规 fdId 并置 synced
*/
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
index 6a162c95..52aefe9e 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
@@ -28,6 +28,7 @@ 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.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.dto.HcsBpmOperationDTO;
@@ -234,6 +235,32 @@ public class HcsProjectSyncSupport {
return page.getContent().get(0).getFdId();
}
+ /**
+ * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
+ *
+ * @param id 项目立项单ID
+ * @return 推送结果描述
+ */
+ public String repushProject(Long id) {
+ ProjectApply projectApply = projectApplyMapper.selectById(id);
+ if (projectApply == null) {
+ throw new ServiceException("项目立项单不存在:" + id);
+ }
+ if (!List.of("approved", "change_approved").contains(projectApply.getApprovalStatus())) {
+ throw new ServiceException("仅审批通过的项目立项可推送合规(当前状态:" + projectApply.getApprovalStatus() + ")");
+ }
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
+ projectApply.setHcsRetryCount(0);
+ projectApplyMapper.updateById(projectApply);
+ try {
+ return pushProject(projectApply);
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ markFailed(projectApply, message);
+ throw new ServiceException("HCS项目立项推送失败:" + message);
+ }
+ }
+
/**
* 回填合规 fdId 并置 synced
*/
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
new file mode 100644
index 00000000..896a3d21
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java
@@ -0,0 +1,178 @@
+/**
+ * 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.task;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.core.redis.cache.BladeRedis;
+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.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springblade.transport.support.hcs.HcsContractSyncSupport;
+import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * HCS 合规推送定时扫描任务(设计文档 §5.0,ADR-0001)
+ *
+ * 审批代码零埋点:扫描只认 approvalStatus=approved/change_approved 且 hcs_sync_status=unsynced/failed(重试未超限),
+ * 与审批入口(/approve、MK 回调)无关;失败落 failed 由下轮扫描自动补偿,超限仅人工重推(D7)。
+ * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 P2 接线)。
+ * 多实例部署时以 Redis 分布式锁防重扫(照既有 BladeRedis 用法,单实例部署行为与既有金蝶任务一致)。
+ * 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts,供手动端点(HcsSyncController)复用。
+ *
+ * 调度配置(Nacos thirdParty.hcs.push.*):sync-enabled 总开关(默认关,灰度打开)、cron、batch-size、max-retry。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsPushTask {
+
+ /**
+ * 分布式锁 TTL(秒):须小于 cron 间隔(默认 5 分钟)且大于单轮预期执行时长,实例崩溃后锁自动过期
+ */
+ private static final long PUSH_TASK_LOCK_TTL_SECONDS = 240L;
+
+ /**
+ * 审批通过状态集合(含变更通过)
+ */
+ private static final List APPROVED_STATUSES = List.of("approved", "change_approved");
+
+ private final HcsProperties hcsProperties;
+ private final ProjectApplyMapper projectApplyMapper;
+ private final ContractManageMapper contractManageMapper;
+ private final HcsProjectSyncSupport hcsProjectSyncSupport;
+ private final HcsContractSyncSupport hcsContractSyncSupport;
+ private final BladeRedis bladeRedis;
+
+ /**
+ * HCS 推送任务开关,默认关闭(灰度打开)
+ */
+ @Value("${thirdParty.hcs.push.sync-enabled:false}")
+ private boolean syncEnabled;
+
+ /**
+ * HCS 推送调度,默认每 5 分钟(准实时首推)
+ */
+ @Scheduled(cron = "${thirdParty.hcs.push.cron:0 */5 * * * ?}")
+ public void pushHcs() {
+ if (!syncEnabled) {
+ return;
+ }
+ // 多实例部署防重扫:setIfAbsent 抢锁,持有值比对后释放
+ String lockHolder = UUID.randomUUID().toString();
+ Boolean locked = bladeRedis.getValueOps()
+ .setIfAbsent(HcsSyncConstant.PUSH_TASK_LOCK_KEY, lockHolder, Duration.ofSeconds(PUSH_TASK_LOCK_TTL_SECONDS));
+ if (!Boolean.TRUE.equals(locked)) {
+ log.info("HCS推送任务其他实例执行中,本轮跳过");
+ return;
+ }
+ try {
+ // 任务顺序(设计文档 §5.0):项目 → 合同;两类互不影响
+ log.info("定时推送HCS项目立项:{}", pushUnsyncedProjects());
+ log.info("定时推送HCS合同:{}", pushUnsyncedContracts());
+ } finally {
+ if (lockHolder.equals(bladeRedis.get(HcsSyncConstant.PUSH_TASK_LOCK_KEY))) {
+ bladeRedis.del(HcsSyncConstant.PUSH_TASK_LOCK_KEY);
+ }
+ }
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的项目立项(C102),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedProjects() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List projectList = projectApplyMapper.selectList(Wrappers.lambdaQuery()
+ .in(ProjectApply::getApprovalStatus, APPROVED_STATUSES)
+ .in(ProjectApply::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(ProjectApply::getHcsRetryCount).or().lt(ProjectApply::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (ProjectApply projectApply : projectList) {
+ try {
+ hcsProjectSyncSupport.pushProject(projectApply);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsProjectSyncSupport.markFailed(projectApply, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", projectList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS项目立项推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+
+ /**
+ * 扫描并推送待同步/待补偿的合同(C103),返回结果汇总
+ *
+ * @return 汇总描述
+ */
+ public String pushUnsyncedContracts() {
+ int maxRetry = hcsProperties.getPush().getMaxRetry();
+ List contractList = contractManageMapper.selectList(Wrappers.lambdaQuery()
+ .in(ContractManage::getApprovalStatus, APPROVED_STATUSES)
+ .in(ContractManage::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
+ .and(wrapper -> wrapper.isNull(ContractManage::getHcsRetryCount).or().lt(ContractManage::getHcsRetryCount, maxRetry))
+ .last("limit " + hcsProperties.getPush().getBatchSize()));
+ int successCount = 0;
+ int failCount = 0;
+ for (ContractManage contractManage : contractList) {
+ try {
+ hcsContractSyncSupport.pushContract(contractManage);
+ successCount++;
+ } catch (Exception exception) {
+ String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
+ hcsContractSyncSupport.markFailed(contractManage, message);
+ failCount++;
+ }
+ }
+ String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", contractList.size(), successCount, failCount, maxRetry);
+ if (failCount > 0) {
+ log.warn("HCS合同推送存在失败:{}", summary);
+ }
+ return summary;
+ }
+}