背景与范围
本文档梳理 "CCC → 平台" 的合同相关同步链路,覆盖:
- 合同头同步(平台
ServiceAccount) - 合同子项同步(平台
ServiceAccItem) - 实施计划同步(平台
Plan) - 前端"一键更新合同"(按钮:
更新合同)触发的后端流程与返回数据
文档以当前实现为准,关键代码均从项目中摘录,便于后续排查与迭代。
名词与对象映射(CCC 字段 → 平台概念)
- 合同头
- CCC:
HTBH(合同编号)、HTNAME(合同名称)、DDNAME(订单编号)、FWXX(服务简介) - 平台:
ServiceAccount.contractNum / name / orderNum / serviceBrief
- CCC:
- 合同子项
- CCC:
HTZXBH(子项编号,本文也用 accId)、HTZXNAME(子项名称)、HTZXLX(子项类型) - 平台:
ServiceAccItem.itemId / itemName / itemType
- CCC:
- 实施计划
- CCC:
SSRWID(实施计划外部ID)、SSRWNAME(标题)、SSRWKSRQ/SSRWJSRQ(起止)、SSRWNR(内容)、LASTMODIFYDATE - 平台:
Plan.externalId / title / startDate / endDate / deliver / externalLastModifyTime
- CCC:
入口与调用关系总览
一键更新合同(前端"更新合同"按钮)
前端组件: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
}
备注:后端返回的统计字段(如
itemsUpdatedCount、plansCreatedCount等)前端用于拼接提示。
后端统一入口:同步 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→ 平台员工表解析)
不会更新/覆盖的字段(保护性约束)
该同步刻意 不通过本接口 修改以下字段:
companyId、orderType:作为平台关键字段必须存在(为空直接报错/失败)amount、deliveryRatio、estimatePersonDays、environment等:不在 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 子项关键字段同步到平台 已存在的 子项记录。
核心规则:
- 只更新,不新增 :若平台不存在该
accId(HTZXBH),则统计为itemsSkippedNotExist并跳过。 - 更新触发条件:
- CCC
LASTMODIFYDATE比平台ServiceAccItem.lastModifyDate新;或 - 即使
LASTMODIFYDATE未变,但关键字段对比不一致(兜底强制刷新)。
- CCC
- 同步时会显式保留平台关键关联字段(合同/公司/状态/审批等),避免被 CCC 覆盖。
该步骤主要更新字段(通过 ServiceAccItemDto 再走 contractService.itemCreate(dto) 落库):
itemName / itemTypeonSite / onSiteCitystartTime / endTimeitemQtyplanHourownerNamelastModifyDate
关键代码(摘录):
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 不一致" 也会刷新(纠偏)
会写入/刷新哪些字段
titlestartDate / endDatedeliverexternalIdexternalLastModifyTime- 关联字段:
companyId / contractId / accId checkType(由HTZXLX推断)- 执行人:
executorList / executorIds - 负责人:
createdBy(尽量取 CCC 项目经理解析结果,失败回退合同经理) - 固定字段:
ignoreWeekend=false、status=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)itemsUpdatedCountitemsSkippedNotExistCountplansCreatedCountplansUpdatedCountplansNoChangeCountplansErrorCounterrors(List)ambiguousPlans(List,用于前端弹窗)needsPlanResolution(boolean)
前端交互:
ambiguousPlans非空 → 打开弹窗,用户选择后二次提交同接口,并在请求体中携带planResolutions。