From c34a5b5bc53fd82e3505a34059487a27a3ba1075 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 22:51:42 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20HCS=E9=A1=B9=E7=9B=AE=E7=AB=8B=E9=A1=B9?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=94=AF=E6=92=91=EF=BC=88C102-3=E5=88=A4?=
=?UTF-8?q?=E9=87=8D/C102-1=E6=96=B0=E5=BB=BA/C102-2=E6=9B=B4=E6=96=B0/?=
=?UTF-8?q?=E5=90=88=E8=A7=84fdId=E5=9B=9E=E5=A1=AB=E4=B8=8E=E5=A4=B1?=
=?UTF-8?q?=E8=B4=A5=E8=A1=A5=E5=81=BF=E5=9B=9E=E5=86=99=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../support/hcs/HcsProjectSyncSupport.java | 309 ++++++++++++++++++
1 file changed, 309 insertions(+)
create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
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
new file mode 100644
index 00000000..6a162c95
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java
@@ -0,0 +1,309 @@
+/**
+ * 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.HcsBpmOperationDTO;
+import org.springblade.hcs.dto.HcsPageQueryDTO;
+import org.springblade.hcs.dto.HcsPageVO;
+import org.springblade.hcs.dto.HcsProjectAddDTO;
+import org.springblade.hcs.dto.HcsProjectFormDataDTO;
+import org.springblade.hcs.dto.HcsProjectPropsDTO;
+import org.springblade.hcs.dto.HcsProjectUpdateDTO;
+import org.springblade.hcs.dto.HcsProjectVO;
+import org.springblade.hcs.dto.HcsRefVO;
+import org.springblade.hcs.dto.HcsFormSaveVO;
+import org.springblade.hcs.dto.HcsResult;
+import org.springblade.hcs.dto.HcsSaveOptionDTO;
+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.constant.HcsSyncConstant;
+import org.springblade.transport.mapper.ProjectApplyMapper;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * HCS 项目立项(C102)同步支撑组件(设计文档 §5.1)
+ *
+ * 触发与链路:审批通过(approved)→ C102-1 新建;变更审批通过(change_approved,hcs_fd_id 已存在)→ C102-2 更新。
+ * 推送前判重:C102-3 conditions={"fd_num": projectCode},命中则直接回填 fdId 置 synced(跳过新建,§9-U4 查询能力联调确认)。
+ * 失败不在此回写,由调用方(HcsPushTask/手动端点)统一 {@link #markFailed} 落 failed 状态进补偿重试。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HcsProjectSyncSupport {
+
+ /**
+ * TMS 万元 → HCS 元换算倍率
+ */
+ private static final BigDecimal WAN_TO_YUAN = BigDecimal.valueOf(10000);
+
+ private final IHcsClient hcsClient;
+ private final HcsProperties hcsProperties;
+ private final ProjectApplyMapper projectApplyMapper;
+
+ /**
+ * 推送项目立项到 HCS:判重 → 新建(C102-1)/ 更新(C102-2)→ 回填合规 fdId 置 synced
+ *
+ * @param projectApply 项目立项单(approvalStatus 须为 approved/change_approved)
+ * @return 推送结果描述(供任务汇总与 hcs_sync_msg 落库)
+ */
+ public String pushProject(ProjectApply projectApply) {
+ // 首推先判重(C102-3),命中直接回填跳过新建;已有合规ID(变更单)走更新
+ if (StringUtil.isBlank(projectApply.getHcsFdId())) {
+ String existFdId = findExistFdId(projectApply);
+ if (StringUtil.isNotBlank(existFdId)) {
+ markSynced(projectApply, existFdId, "判重命中,回填已有合规项目ID");
+ return "判重命中,回填已有合规项目ID";
+ }
+ String fdId = addProject(projectApply);
+ markSynced(projectApply, fdId, "C102-1 项目立项新建成功");
+ return "C102-1 项目立项新建成功";
+ }
+ updateProject(projectApply);
+ markSynced(projectApply, projectApply.getHcsFdId(), "C102-2 项目立项更新成功");
+ return "C102-2 项目立项更新成功";
+ }
+
+ /**
+ * C102-1 项目立项新建,返回合规项目 fdId
+ */
+ private String addProject(ProjectApply projectApply) {
+ String operatorLoginName = requireLoginName(projectApply, "C102-1");
+ HcsProjectAddDTO param = new HcsProjectAddDTO();
+
+ HcsBpmOperationDTO bpmOperation = new HcsBpmOperationDTO();
+ // 操作类型:固定 drafter_submit(以起草人身份直接提交流程)
+ bpmOperation.setOperationType("drafter_submit");
+ // 【必填】立项表单模板编码:配置 thirdParty.hcs.project.template-code(环境值,§9-U9)
+ bpmOperation.setTemplateCode(requireTemplateCode());
+ param.setBpmOperation(bpmOperation);
+ // 是否校验权限:接口调用固定 false
+ param.setCheckAuth(false);
+ // 【必填】当前操作人:项目经办人登录名(§9-U2 登录名同源性联调确认)
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ param.setFormData(buildFormData(projectApply, null));
+ HcsSaveOptionDTO saveOption = new HcsSaveOptionDTO();
+ // saveDraft=false 直接提交
+ saveOption.setSaveDraft(false);
+ param.setSaveOption(saveOption);
+
+ log.info("HCS项目立项推送开始:{}(C102-1 新建)", projectApply.getProjectCode());
+ HcsResult result = hcsClient.projectAdd(param);
+ if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
+ log.error("HCS项目立项新建失败响应:{},projectCode={}", result == null ? "无响应" : result.getMsg(), projectApply.getProjectCode());
+ throw new HcsException("C102-1 项目立项新建失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ return result.getData().getFdId();
+ }
+
+ /**
+ * C102-2 项目立项更新(入参同新建,去掉 bpmOperation/saveOption,formData.fdId 必填)
+ */
+ private void updateProject(ProjectApply projectApply) {
+ String operatorLoginName = requireLoginName(projectApply, "C102-2");
+ HcsProjectUpdateDTO param = new HcsProjectUpdateDTO();
+ // 是否校验权限:接口调用固定 false
+ param.setCheckAuth(false);
+ // 【必填】当前操作人:项目经办人登录名
+ param.setCurUser(HcsUserRefVO.ofLoginName(operatorLoginName));
+ // 更新必须携带合规项目 fdId
+ param.setFormData(buildFormData(projectApply, projectApply.getHcsFdId()));
+
+ log.info("HCS项目立项推送开始:{}(C102-2 更新)", projectApply.getProjectCode());
+ HcsResult result = hcsClient.projectUpdate(param);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS项目立项更新失败响应:{},projectCode={}", result == null ? "无响应" : result.getMsg(), projectApply.getProjectCode());
+ throw new HcsException("C102-2 项目立项更新失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ }
+
+ /**
+ * 组装项目立项 formData(新建/更新共用,字段标注照 §3.4 规范,映射表见设计文档 §5.1)
+ */
+ private HcsProjectFormDataDTO buildFormData(ProjectApply projectApply, String fdId) {
+ HcsProjectFormDataDTO formData = new HcsProjectFormDataDTO();
+ // 合规项目 fdId(C102-2 更新必填,C102-1 不传)
+ formData.setFdId(fdId);
+ HcsProjectPropsDTO props = new HcsProjectPropsDTO();
+ // 【必填】项目名称:ProjectApply.projectName
+ props.setFdProName(projectApply.getProjectName());
+ // 项目简称:ProjectApply.projectShortName
+ props.setFdProSimpleName(projectApply.getProjectShortName());
+ // 【必填】项目编号:ProjectApply.projectCode(判重候选键)
+ props.setFdNum(projectApply.getProjectCode());
+ // 创建单位:TMS 开题部门名称(HCS 组织 fdId 与 TMS 不同源,先传名称,§9-U2)
+ props.setFdCreatorUnit(HcsRefVO.ofName(projectApply.getBusinessDeptName()));
+ // 项目负责人:负责人登录名 {fdLoginName}(§9-U2)
+ props.setFdHead(resolveUserRef(projectApply.getPrincipalUserId()));
+ // 承接部门:TMS 承接部门名称(同 fd_creator_unit 先传名称)
+ props.setFdTakerDept(HcsRefVO.ofName(projectApply.getUndertakeDeptName()));
+ // 承接人:项目经办人登录名 {fdLoginName}
+ props.setFdTaker(resolveUserRef(projectApply.getHandlerUserId()));
+ // 付款额度:TMS fundLimit(万元)× 10000 转 HCS 元
+ props.setFdPayLimit(toYuan(projectApply.getFundLimit()));
+ // 开票额度:TMS receivableLimit(万元)× 10000 转 HCS 元
+ props.setFdOutInvoiceLimit(toYuan(projectApply.getReceivableLimit()));
+ // 项目建设内容:ProjectApply.situationRemark
+ props.setFdProContent(projectApply.getSituationRemark());
+ // 是否可用:固定 true
+ props.setFdIsAvailable(true);
+ // 可用单位列表:与创建单位一致,先传名称
+ props.setFdAvailUnits(List.of(HcsRefVO.ofName(projectApply.getBusinessDeptName())));
+ // 结算类型:TMS settlementMode 原值(字典映射待联调,§9-U3)
+ props.setFdSettlementType(projectApply.getSettlementMode());
+ // 计划保证金:TMS fundDemand(万元)× 10000 转 HCS 元(变更映射曾名"履约保证金",语义联调确认)
+ props.setFdProPlanDeposit(toYuan(projectApply.getFundDemand()));
+ // 是否自营项目:一期固定 "1"(联调确认)
+ props.setFdIsSelfPro("1");
+ // TODO 待确认:项目收入目标 fd_pro_income_goal,TMS 无直接对应字段,一期不传(§5.1)
+ // 金蝶同步状态:固定 "1"
+ props.setFdKingdeeSyncStatus("1");
+ // 来源标识:fd_sources,配置 thirdParty.hcs.src-system(约定值 TMS,D14)
+ props.setFdSources(hcsProperties.getSrcSystem());
+ // 来源系统主键 ID:TMS 项目立项单主键(D14,对账/幂等候选键)
+ props.setFdKingdeeId(String.valueOf(projectApply.getId()));
+ // 创建人:项目经办人登录名(扫描推送无登录上下文,取单据承办人,§9-U2)
+ props.setFdCreator(resolveUserRef(projectApply.getHandlerUserId()));
+ formData.setDynamicProps(props);
+ return formData;
+ }
+
+ /**
+ * C102-3 项目立项查询判重(conditions={"fd_num": projectCode},§9-U4 查询能力联调确认)
+ */
+ private String findExistFdId(ProjectApply projectApply) {
+ HcsPageQueryDTO query = new HcsPageQueryDTO();
+ // C102-3 入参 columns 用下划线字段名
+ query.setColumns(List.of("fd_id", "fd_num"));
+ // 判重条件:项目编号精确匹配
+ query.setConditions(Map.of("fd_num", Objects.toString(projectApply.getProjectCode(), "")));
+ query.setOffset(0);
+ query.setPageSize(10);
+ query.setSorts(Map.of("fd_create_time", "desc"));
+ // 查询需携带当前操作人(复用单据承办人登录名)
+ query.setCurUser(HcsUserRefVO.ofLoginName(requireLoginName(projectApply, "C102-3")));
+ HcsResult> result = hcsClient.projectList(query);
+ if (result == null || !result.isSuccess()) {
+ log.error("HCS项目立项判重查询失败响应:{},projectCode={}", result == null ? "无响应" : result.getMsg(), projectApply.getProjectCode());
+ throw new HcsException("C102-3 项目立项判重查询失败:" + (result == null ? "响应为空" : result.getMsg()));
+ }
+ HcsPageVO page = result.getData();
+ if (page == null || page.getContent() == null || page.getContent().isEmpty()) {
+ return null;
+ }
+ return page.getContent().get(0).getFdId();
+ }
+
+ /**
+ * 回填合规 fdId 并置 synced
+ */
+ public void markSynced(ProjectApply projectApply, String fdId, String message) {
+ projectApply.setHcsFdId(fdId);
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_SYNCED);
+ projectApply.setHcsRetryCount(0);
+ projectApply.setHcsSyncTime(LocalDateTime.now());
+ projectApply.setHcsSyncMsg(message);
+ projectApplyMapper.updateById(projectApply);
+ log.info("HCS项目立项推送成功:projectCode={},fdId={},{}", projectApply.getProjectCode(), fdId, message);
+ }
+
+ /**
+ * 推送失败回写:failed + 重试次数+1 + 失败原因(供扫描任务按 hcs_retry_count < max-retry 自动补偿)
+ */
+ public void markFailed(ProjectApply projectApply, String message) {
+ projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_FAILED);
+ projectApply.setHcsRetryCount(projectApply.getHcsRetryCount() == null ? 1 : projectApply.getHcsRetryCount() + 1);
+ projectApply.setHcsSyncTime(LocalDateTime.now());
+ projectApply.setHcsSyncMsg(message);
+ projectApplyMapper.updateById(projectApply);
+ log.error("HCS项目立项推送失败:projectCode={},retryCount={},{}", projectApply.getProjectCode(), projectApply.getHcsRetryCount(), message);
+ }
+
+ /**
+ * 立项表单模板编码(未配置直接失败,提示配置项,§9-U9)
+ */
+ private String requireTemplateCode() {
+ String templateCode = hcsProperties.getProject().getTemplateCode();
+ if (StringUtil.isBlank(templateCode)) {
+ throw new HcsException("HCS项目立项推送失败,立项表单模板编码未配置(thirdParty.hcs.project.template-code,§9-U9)");
+ }
+ return templateCode;
+ }
+
+ /**
+ * 项目经办人登录名(curUser 必填,解析不到直接失败并提示)
+ */
+ private String requireLoginName(ProjectApply projectApply, String scene) {
+ String loginName = resolveLoginName(projectApply.getHandlerUserId());
+ if (StringUtil.isBlank(loginName)) {
+ throw new HcsException("HCS项目立项推送失败(" + scene + "),经办人登录名解析不到(handlerUserId=" + projectApply.getHandlerUserId() + ",§9-U2)");
+ }
+ return loginName;
+ }
+
+ /**
+ * 按用户ID 解析登录名引用(查不到返回 null,调用方按字段必填性兜底)
+ */
+ private HcsUserRefVO resolveUserRef(Long userId) {
+ String loginName = resolveLoginName(userId);
+ return StringUtil.isBlank(loginName) ? null : HcsUserRefVO.ofLoginName(loginName);
+ }
+
+ /**
+ * 按用户ID 解析登录名(UserCache 惯例)
+ */
+ private String resolveLoginName(Long userId) {
+ if (userId == null) {
+ return null;
+ }
+ User user = UserCache.getUser(userId);
+ return user == null ? null : user.getAccount();
+ }
+
+ /**
+ * TMS 万元 → HCS 元
+ */
+ private BigDecimal toYuan(BigDecimal wanAmount) {
+ return wanAmount == null ? null : wanAmount.multiply(WAN_TO_YUAN);
+ }
+}