合同同步逻辑

背景与范围

本文档梳理 "CCC → 平台" 的合同相关同步链路,覆盖:

  • 合同头同步(平台 ServiceAccount
  • 合同子项同步(平台 ServiceAccItem
  • 实施计划同步(平台 Plan
  • 前端"一键更新合同"(按钮:更新合同)触发的后端流程与返回数据

文档以当前实现为准,关键代码均从项目中摘录,便于后续排查与迭代。


名词与对象映射(CCC 字段 → 平台概念)

  • 合同头
    • CCC:HTBH(合同编号)、HTNAME(合同名称)、DDNAME(订单编号)、FWXX(服务简介)
    • 平台:ServiceAccount.contractNum / name / orderNum / serviceBrief
  • 合同子项
    • CCC:HTZXBH(子项编号,本文也用 accId)、HTZXNAME(子项名称)、HTZXLX(子项类型)
    • 平台:ServiceAccItem.itemId / itemName / itemType
  • 实施计划
    • CCC:SSRWID(实施计划外部ID)、SSRWNAME(标题)、SSRWKSRQ/SSRWJSRQ(起止)、SSRWNR(内容)、LASTMODIFYDATE
    • 平台:Plan.externalId / title / startDate / endDate / deliver / externalLastModifyTime

入口与调用关系总览

一键更新合同(前端"更新合同"按钮)

前端组件:service-front/src/components/CccContractItems.vue

  • 点击"更新合同"后,构造 CccContractHeadSyncRequest 请求体并调用 syncCccContractHeadApi
  • 若后端返回实施计划歧义列表 ambiguousPlans,前端弹窗选择后再次提交,并携带 planResolutions

关键前端代码(摘录):

ts 复制代码
// service-front/src/components/CccContractItems.vue
const buildSyncContractHeadPayload = (contractRow: any) => {
  // ...从同合同下多条子项里挑选第一个非空字段,避免单行字段为空导致后端不更新
  return {
    platformContractId: contractRow.platformContractId,
    contractNum: src.HTBH || contractRow.id || '',
    contractName: src.HTNAME || contractRow.name || '',
    orderNum: src.DDNAME || '',
    serviceBrief: src.FWXX || '',
    clientPeople: src.NAME || '',
    clientPhone: src.KHLXRSJH || '',
    clientEmail: src.KHLXRDZYX || '',
    contractStartDate: pickFirstCccFieldAcrossItems(sameContractItems, ['HTKSRQ']) || src.HTKSRQ || '',
    contractEndDate: pickFirstCccFieldAcrossItems(sameContractItems, ['HTJSRQ']) || src.HTJSRQ || '',
    actualStartDate: pickFirstCccFieldAcrossItems(sameContractItems, ['HTJFKSRQ', 'HTKSRQ']) || src.HTJFKSRQ || src.HTKSRQ || '',
    actualEndDate: pickFirstCccFieldAcrossItems(sameContractItems, ['HTJFJSRQ', 'HTJSRQ']) || src.HTJFJSRQ || src.HTJSRQ || '',
    ...(projectManagerName ? { projectManagerName } : {}),
    ...(contractOwnerName ? { contractOwnerName } : {})
  }
}

const handleSyncContractHead = async (contractRow: any) => {
  const base = buildSyncContractHeadPayload(contractRow)
  const { data } = await syncCccContractHeadApi(base as any)
  // 若 ambiguousPlans 非空,则弹窗并二次提交 planResolutions
}

备注:后端返回的统计字段(如 itemsUpdatedCountplansCreatedCount 等)前端用于拼接提示。


后端统一入口:同步 CCC 合同头(含子项更新与实施计划同步)

后端控制器:enmo_support/src/main/java/com/enmo/enmo_support/user/controller/ServiceContractController.java

接口:

  • POST /service/ccc/syncContractHead
  • 请求 DTO:CccContractHeadSyncRequest

接口设计特点:

  • Step A:更新合同头(只更新"非关键字段")
  • Step B:更新合同子项(update-only:只更新平台已存在的子项,不新增;不存在的子项会跳过)
  • Step C:同步实施计划(create-or-update:不存在则创建,存在则按外部最后修改时间与 createdBy 差异决定是否更新)
  • 若实施计划存在歧义(同一 accId + SSRWID 命中多条本地 Plan),返回 ambiguousPlans,前端弹窗选择后携带 planResolutions 二次提交

关键后端代码(摘录):

java 复制代码
// ServiceContractController#syncContractHead
// Step A:更新合同头
updateContractHeadFromCcc(contract, req);

// Step B:更新合同子项(update-only,不新增)
// 1) 拉取 CCC 子项全量数据
// 2) 仅处理当前合同号 htbh 下的子项
// 3) 仅当本地存在该 accId(HTZXBH)时才更新,否则统计为 skipped

// Step C:同步实施计划(create-or-update)
// 对本合同在平台已存在的全部合同子项同步实施计划;
// 多条歧义时返回 ambiguousPlans,可二次提交 planResolutions

Step A:合同头同步(ServiceAccount)

方法:updateContractHeadFromCcc(ServiceAccount contract, CccContractHeadSyncRequest req)

会更新的字段(平台合同表)

  • name(合同名称)
  • serviceBrief(服务简介)
  • orderNum(订单编号)
  • clientPeople / clientPhone / clientEmail(客户联系人信息)
  • contractStartTime / contractEndTime(约定起止)
  • startTime / endTime(实际起止)
  • managerId(项目经理:由 projectManagerName → 平台员工表解析)
  • contractOwnerId(合同所有人:由 contractOwnerName → 平台员工表解析)

不会更新/覆盖的字段(保护性约束)

该同步刻意 不通过本接口 修改以下字段:

  • companyIdorderType:作为平台关键字段必须存在(为空直接报错/失败)
  • amountdeliveryRatioestimatePersonDaysenvironment 等:不在 CCC 同步范围内

关键代码(摘录):

java 复制代码
private void updateContractHeadFromCcc(ServiceAccount contract, CccContractHeadSyncRequest req) {
    if (StringUtils.isNotBlank(req.getContractName())) contract.setName(req.getContractName());
    if (StringUtils.isNotBlank(req.getServiceBrief())) contract.setServiceBrief(req.getServiceBrief());
    if (StringUtils.isNotBlank(req.getOrderNum())) contract.setOrderNum(req.getOrderNum());
    if (StringUtils.isNotBlank(req.getClientPeople())) contract.setClientPeople(req.getClientPeople());
    if (StringUtils.isNotBlank(req.getClientPhone())) contract.setClientPhone(req.getClientPhone());
    if (StringUtils.isNotBlank(req.getClientEmail())) contract.setClientEmail(req.getClientEmail());

    if (StringUtils.isNotBlank(req.getContractStartDate())) contract.setContractStartTime(parseDate(req.getContractStartDate()));
    if (StringUtils.isNotBlank(req.getContractEndDate())) contract.setContractEndTime(parseDate(req.getContractEndDate()));
    if (StringUtils.isNotBlank(req.getActualStartDate())) contract.setStartTime(parseDate(req.getActualStartDate()));
    if (StringUtils.isNotBlank(req.getActualEndDate())) contract.setEndTime(parseDate(req.getActualEndDate()));

    if (StringUtils.isNotBlank(req.getProjectManagerName())) {
        Integer managerUserId = resolvePlatformUserIdByEmployeeName(StringUtils.trim(req.getProjectManagerName()));
        if (managerUserId != null) contract.setManagerId(managerUserId);
    }
    if (StringUtils.isNotBlank(req.getContractOwnerName())) {
        Integer ownerUserId = resolvePlatformUserIdByEmployeeName(StringUtils.trim(req.getContractOwnerName()));
        if (ownerUserId != null) contract.setContractOwnerId(ownerUserId);
    }

    // 保留平台关键字段:companyId / orderType;不通过本接口更新 amount、deliveryRatio、estimatePersonDays、environment
    if (contract.getOrderType() == null || contract.getCompanyId() == null) {
        throw new EmcsCustomException("平台合同关键字段缺失,无法同步合同头");
    }
    contractService.create(contract);
}

Step B:合同子项同步(ServiceAccItem,update-only)

目标:在"一键更新合同"时,将 CCC 子项关键字段同步到平台 已存在的 子项记录。

核心规则:

  1. 只更新,不新增 :若平台不存在该 accId(HTZXBH),则统计为 itemsSkippedNotExist 并跳过。
  2. 更新触发条件:
    • CCC LASTMODIFYDATE 比平台 ServiceAccItem.lastModifyDate 新;或
    • 即使 LASTMODIFYDATE 未变,但关键字段对比不一致(兜底强制刷新)。
  3. 同步时会显式保留平台关键关联字段(合同/公司/状态/审批等),避免被 CCC 覆盖。

该步骤主要更新字段(通过 ServiceAccItemDto 再走 contractService.itemCreate(dto) 落库):

  • itemName / itemType
  • onSite / onSiteCity
  • startTime / endTime
  • itemQty
  • planHour
  • ownerName
  • lastModifyDate

关键代码(摘录):

java 复制代码
// needUpdateByLastModify:按 CCC LASTMODIFYDATE 判定是否更新
// needUpdateByDiff:即使 LASTMODIFYDATE 未变,只要关键字段变化也更新
boolean needUpdateByDiff =
    !equalsDate(existing.getStartTime(), newStart)
    || !equalsDate(existing.getEndTime(), newEnd)
    || !StringUtils.equals(StringUtils.trimToEmpty(existing.getOnSiteCity()), StringUtils.trimToEmpty(newCity))
    || !Boolean.TRUE.equals(existing.getOnSite()) == newOnSite
    || (newQty != null && (existing.getItemQty() == null || Math.abs(existing.getItemQty() - newQty) > 1e-6))
    || (newOwnerName != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getOwnerName()), StringUtils.trimToEmpty(newOwnerName)))
    || (newPlanHour != null && !Objects.equals(existing.getPlanHour(), newPlanHour));

ServiceAccItemDto dto = new ServiceAccItemDto();
dto.setItemId(existing.getItemId());
dto.setItemName(cccItem.getItemName());
dto.setItemType(cccItem.getItemType());
dto.setOnSite(newOnSite);
dto.setOnSiteCity(newCity);
dto.setStartTime(newStart);
dto.setEndTime(newEnd);
dto.setItemQty(newQty != null ? newQty : existing.getItemQty());
dto.setPlanHour(newPlanHour != null ? newPlanHour : existing.getPlanHour());
dto.setOwnerName(newOwnerName != null ? newOwnerName : existing.getOwnerName());
// 保留平台关键关联字段 + 其他字段
dto.setContractNum(existing.getContractNum());
dto.setCompanyId(existing.getCompanyId());
dto.setCompanyName(existing.getCompanyName());
dto.setOrderNum(existing.getOrderNum());
dto.setStatus(existing.getStatus());
dto.setDbaLevel(existing.getDbaLevel());
dto.setLastModifyDate(cccLast != null ? cccLast : existing.getLastModifyDate());

contractService.itemCreate(dto);

Step C:实施计划同步(Plan,create-or-update)

目标:对本合同在平台已存在的全部合同子项(accId 集合)拉取 CCC 实施计划,并同步到平台 Plan

accId 范围

accIdsForStepC 的构成:

  • 本合同在平台下已有的全部子项(contractService.getAccList(companyId, null, contractId, true)
  • Step B 本次成功更新的 accId
  • 二次提交 planResolutions 中出现的 accId(用于歧义消解)

主键与匹配策略

  • 逻辑主键:accId + externalId(SSRWID)
    • planDao.listByAccIdAndExternalId(accId, externalId) 查本地候选
  • 若候选 =1:直接视为 existingPlan
  • 若候选 >1
    • 若请求携带 planResolutions 且命中 selectedPlanId,则选定那条更新
    • 否则将该组候选打包进 ambiguousPlans 返回前端弹窗选择

是否更新的判断

当存在 existingPlan 时,如果满足以下任一条件则会更新:

  • CCC LASTMODIFYDATE(解析为 cccLast)更新较新(或本地 externalLastModifyTime 为空)
  • 或者 即使时间戳未变 ,但 "平台计划 createdBy 与 CCC 项目经理解析得到的 userId 不一致" 也会刷新(纠偏)

会写入/刷新哪些字段

  • title
  • startDate / endDate
  • deliver
  • externalId
  • externalLastModifyTime
  • 关联字段:companyId / contractId / accId
  • checkType(由 HTZXLX 推断)
  • 执行人:executorList / executorIds
  • 负责人:createdBy(尽量取 CCC 项目经理解析结果,失败回退合同经理)
  • 固定字段:ignoreWeekend=falsestatus=0

关键代码(摘录):

java 复制代码
Plan plan = new Plan();
plan.setTitle(cccPlan.getSSRWNAME());
plan.setStartDate(parseDate(cccPlan.getSSRWKSRQ()));
plan.setEndDate(parseDate(cccPlan.getSSRWJSRQ()));
plan.setDeliver(cccPlan.getSSRWNR());
plan.setExternalId(externalId);
plan.setExternalLastModifyTime(cccLast);

// 关联字段:尽量来自本地(以 accId 对应的合同/公司为准)
if (localContract != null) {
    plan.setCompanyId(localContract.getCompanyId());
    plan.setContractId(localContract.getId());
} else if (localItem != null) {
    plan.setCompanyId(localItem.getCompanyId());
}
plan.setAccId(accId);

// checkType:由 HTZXLX 推断
plan.setCheckType(determineCheckTypeByHTZXLX(cccPlan.getHTZXLX()));

// 执行人 + createdBy:由 XMJLNAME 解析平台 userId,失败回退合同经理
if (ownerUserId != null) {
    PlanExecutor pe = new PlanExecutor();
    pe.setExecutorId(ownerUserId);
    pe.setExecutorName(ownerName);
    pe.setTaskTime(calculateTaskTimeFromPlanDto(cccPlan));
    plan.setExecutorList(Collections.singletonList(pe));
    plan.setExecutorIds(Collections.singleton(ownerUserId));
    plan.setCreatedBy(ownerUserId);
}

plan.setIgnoreWeekend(false);
plan.setStatus(0);

单条子项保存(非"一键更新合同"的批量链路)

接口:POST /service/ccc/saveContractItem

该接口的职责与 /ccc/syncContractHead 不同:

  • 允许"新建合同 + 新建子项"(当平台合同不存在时)
  • 同时也支持"合同已存在则同步更新合同信息"(调用 updateContractFromCcc(contract, saveDto)
  • 子项保存最终仍通过 contractService.itemCreate(itemDto) 落库

该接口当前在 "子项 startDate/endDate 的解析" 处使用了独立的解析逻辑(OffsetDateTime.parse(saveDto.getStartDate()) 等),与控制器内 parseDate(...)+0000 归一化逻辑不完全一致;如需统一,可在后续专项改造中对齐为同一套解析函数。


返回结构与前端交互约定(syncContractHead)

后端成功时返回:

  • operateMessage:通常为"合同更新成功",若有歧义会提示"请在弹窗中选择..."
  • operateCallBackObj(本文称 stats):
    • contractUpdated(boolean)
    • itemsUpdatedCount
    • itemsSkippedNotExistCount
    • plansCreatedCount
    • plansUpdatedCount
    • plansNoChangeCount
    • plansErrorCount
    • errors(List)
    • ambiguousPlans(List,用于前端弹窗)
    • needsPlanResolution(boolean)

前端交互:

  • ambiguousPlans 非空 → 打开弹窗,用户选择后二次提交同接口,并在请求体中携带 planResolutions

相关推荐
IT_陈寒2 小时前
Vite的public文件夹放静态资源?这坑我替你踩了
前端·人工智能·后端
子兮曰2 小时前
别让爬虫白嫖你的导航站了:纯免费,手把手实现加密字体防爬
前端·javascript·后端
阿苟2 小时前
JAVA重点难点
后端
uzong3 小时前
TIOBE 指数:2026 年编程语言排行榜
后端
小村儿3 小时前
连载06 - Hooks 源码深度解析:Claude Code 的确定性自动化体系
前端·后端·ai编程
用户8356290780513 小时前
使用 Python 设置 Excel 数据验证
后端·python
yoyo_zzm3 小时前
Laravel6.x新特性全解析
java·spring boot·后端
xiaobaoyu3 小时前
ssm
后端
Nick_zcy3 小时前
小说在线阅读网站和小说管理系统 · 功能全解析
java·后端·python·springboot·ruoyi