下文代码摘录自 Spring Boot + MyBatis 风格的后端工程(计划域与外部订单系统对接)。为便于对外发布,与具体租户相关的整型缺省值(如未传组织时的默认客户组织主键)在片段中以
DEFAULT_COMPANY_ID代替,你在自有仓库中按常量或配置项对照即可;其余逻辑与真实实现一致。
1. 先厘清概念:同一张表里的两种「计划」
业务上常把「在 MES 里排的计划」和「从外部系统同步的实施计划」都叫计划,工程上落在同一套持久化模型里,靠字段区分:
| 概念 | 典型标识 | 说明 |
|---|---|---|
| MES 计划任务 | external_id 为空 | 满足权限时可删除。 |
| 外部实施计划 | external_id 非空(对应外部的 SSRWID) | 删除接口直接拒绝,避免与外部主数据脱节。 |
列表查询常用 planType 区分:plan(仅 MES)、implementation(仅带外部 ID 的实施计划)、all。
2. 数据模型与状态码
es_plan:标题、起止时间、status、acc_id、contract_id、external_id、external_last_modify_time、created_by等。es_plan_executor:计划与执行人的多行关系;保存时会维护并可能触发通知。
状态枚举(节选):
public enum PlanState {
CREATED(0, "已创建"),
HANDLING(1, "进行中"),
DONE(2, "已结束"),
OVERDUE(3, "已逾期未结束");
private final Integer code;
private final String desc;
// getByCode ...
}
新建外部实施计划时,构建逻辑里会把 status 固定为 0(已创建),后续由定时任务按时间推进。
3. 三条「创建 / 更新」路径,最后都进 PlanService.save
flowchart LR
subgraph entries [CreateOrUpdateEntries]
PC[PlanController_save]
CCC[CccImplementationPlan_create_update]
SCH[ContractSync_StepC]
end
PS[PlanServiceImpl_save]
DB[(es_plan)]
TM[TimerTask_startPlanTaskLoad]
entries --> PS --> DB
TM --> DB
3.1 路径一:后台 POST /plan/save(强角色)
@ApiOperation("保存实施计划")
@PostMapping("/save")
@PreAuthorize("hasRole('sys')")
public OperationInfo<Object> savePlan(@RequestBody @Validated Plan plan) {
if (Objects.isNull(plan.getCompanyId())) {
plan.setCompanyId(DEFAULT_COMPANY_ID);
}
return planService.save(plan);
}
未带组织时写入业务约定的缺省客户组织(上例用常量名脱敏)。
3.2 路径二:外部实施计划「创建」接口(节选)
校验 SSRWID、accId,按「子项 + 外部计划 ID」查重:多于一条则要求前端选 mesPlanId,禁止静默创建;已有一条则走更新。
@PostMapping("/create")
@ApiOperation("创建CCC实施计划")
public OperationInfo<Object> createCccImplementationPlan(@RequestBody CccPlanMappingDTO dto) {
try {
if (StringUtils.isBlank(dto.getSSRWID())) {
log.error("SSRWID字段为空,无法创建CCC实施计划");
return OperationInfo.failure("CCC实施计划ID不能为空");
}
if (StringUtils.isBlank(dto.getAccId())) {
return OperationInfo.failure("合同子项 accId 不能为空");
}
List<Plan> existingList = planDao.listByAccIdAndExternalId(
StringUtils.trim(dto.getAccId()), StringUtils.trim(dto.getSSRWID()));
if (existingList.size() > 1) {
Map<String, Object> cb = new HashMap<>();
cb.put("needsPlanSelection", true);
cb.put("candidates", toPlanCandidateBriefs(existingList));
return OperationInfo.failure(
"该子项下已存在多条相同 SSRWID 的实施计划,请使用更新接口并传入 mesPlanId", cb);
}
if (existingList.size() == 1) {
return OperationInfo.failure("该CCC实施计划已同步,请使用更新功能");
}
Plan plan = buildPlanFromCcc(dto);
OperationInfo<Object> saveResult = planService.save(plan);
// 创建成功后,确保创建人=负责人(第一个执行人),避免 created_by 落在触发同步的 sys/PA
if (saveResult != null && Boolean.TRUE.equals(saveResult.getSuccess())
&& plan.getId() != null
&& CollectionUtils.isNotEmpty(dto.getExecutorList())
&& dto.getExecutorList().get(0).getExecutorId() != null) {
try {
planDao.updateCreatedBy(plan.getId(), dto.getExecutorList().get(0).getExecutorId());
} catch (Exception ignore) {}
}
if (saveResult.getSuccess() && StringUtils.isNotBlank(dto.getAccId())
&& StringUtils.isNotBlank(dto.getSSRWID())) {
try {
int updateCount = serviceAccItemDao.setDefaultExternalPlanIdIfNull(
dto.getAccId(), dto.getSSRWID());
if (updateCount > 0) {
log.info("自动设置合同子项默认实施计划ID成功: accId={}, externalId={}",
dto.getAccId(), dto.getSSRWID());
}
} catch (Exception e) {
log.warn("自动设置合同子项默认实施计划ID失败(不影响主流程): accId={}, externalId={}, error={}",
dto.getAccId(), dto.getSSRWID(), e.getMessage());
}
}
return saveResult;
} catch (Exception e) {
log.error("创建CCC实施计划失败", e);
return OperationInfo.failure("创建失败:" + e.getMessage());
}
}
从 DTO 构建 Plan(节选):工程里 companyId 缺省与后台保存接口一致;下例 DEFAULT_COMPANY_ID 替代字面量。执行人循环里会补全姓名,并把 createdBy 设为第一个执行人。
private Plan buildPlanFromCcc(CccPlanMappingDTO dto) {
Plan plan = new Plan();
plan.setTitle(dto.getSSRWNAME());
plan.setStartDate(parseDate(dto.getSSRWKSRQ()));
plan.setEndDate(parseDate(dto.getSSRWJSRQ()));
plan.setDeliver(dto.getSSRWNR());
plan.setExternalId(dto.getSSRWID());
Date lastModifyTime = parseDate(dto.getLASTMODIFYDATE());
plan.setExternalLastModifyTime(lastModifyTime);
if (dto.getCompanyId() == null) {
plan.setCompanyId(DEFAULT_COMPANY_ID);
} else {
plan.setCompanyId(dto.getCompanyId());
}
plan.setCheckType(dto.getCheckType());
plan.setAccId(dto.getAccId());
plan.setContractId(dto.getContractId());
if (CollectionUtils.isNotEmpty(dto.getExecutorList())) {
List<PlanExecutor> executorList = new ArrayList<>();
for (CccExecutorDTO executor : dto.getExecutorList()) {
PlanExecutor planExecutor = new PlanExecutor();
planExecutor.setExecutorId(executor.getExecutorId());
String executorName = executor.getExecutorName();
if (StringUtils.isBlank(executorName) && executor.getExecutorId() != null) {
try {
executorName = getUserNameById(executor.getExecutorId());
} catch (Exception e) {
log.warn("获取执行人姓名失败,executorId: {}", executor.getExecutorId(), e);
executorName = "用户" + executor.getExecutorId();
}
}
planExecutor.setExecutorName(executorName);
planExecutor.setTaskTime(executor.getTaskTime() != null
? new java.math.BigDecimal(executor.getTaskTime().toString()) : null);
planExecutor.setRemarks(executor.getRemarks());
executorList.add(planExecutor);
}
plan.setExecutorList(executorList);
Integer ownerId = dto.getExecutorList().get(0).getExecutorId();
if (ownerId != null) {
plan.setCreatedBy(ownerId);
}
}
plan.setIgnoreWeekend(dto.getIgnoreWeekend() != null ? dto.getIgnoreWeekend() : false);
plan.setStatus(0);
return plan;
}
更新路径与创建类似:按 accId + SSRWID 定位行,多条时必须 mesPlanId,再 save 后同样 updateCreatedBy 与负责人对齐。
3.3 路径三:合同同步里的「实施计划批量对齐」
在「同步合同头」一类接口中,按子项拉外部实施计划列表,对每条在本地 新建或更新,底层仍调用 planService.save;同一 (accId, externalId) 命中多条本地行时返回歧义列表,由前端二次提交消解。
跳过更新的条件在「仅信时间戳」基础上增加了 负责人纠偏:若外部最后修改时间未推进,但平台 createdBy 与解析出的项目经理用户 id 不一致,仍继续刷新,避免外部时间戳不变、负责人已变时本地永远不更新。
// 先解析项目经理与本地合同:CCC 的 LASTMODIFYDATE 未变时,若平台 createdBy 与项目经理仍不一致也需刷新
Integer managerUserId = resolvePlatformUserIdByEmployeeName(cccPlan.getXMJLNAME());
ServiceAccItem localItem = serviceAccItemDao.findByAccId(accId);
ServiceAccount localContract = null;
try {
if (localItem != null && StringUtils.isNotBlank(localItem.getContractNum())) {
localContract = contractService.findByContractNumIncludeExpired(
StringUtils.trim(localItem.getContractNum()));
}
} catch (Exception ignore) {}
Integer fallbackManagerId = localContract != null ? localContract.getManagerId() : null;
Integer ownerUserId = managerUserId != null ? managerUserId : fallbackManagerId;
Date cccLast = parseCccDate(cccPlan.getLASTMODIFYDATE());
if (existingPlan != null) {
Date dbLast = existingPlan.getExternalLastModifyTime();
boolean cccHasNewerTimestamp =
dbLast == null || cccLast == null || cccLast.after(dbLast);
boolean createdByDiffersFromPm =
ownerUserId != null && !Objects.equals(ownerUserId, existingPlan.getCreatedBy());
if (!cccHasNewerTimestamp && !createdByDiffersFromPm) {
plansNoChange++;
continue;
}
}
4. 汇合点:save 为何在更新时清空 status
更新分支里,在处理好 externalId 与校验类型之后:
//计划任务的状态变更: 根据时间触发和结束任务接口
plan.setStatus(null);
plan.setEndRealTime(null);
//更新时只处理计划任务主体表单
planDao.update(plan);
这样表单保存标题、执行人、日期等时,不会覆盖定时任务刚改过的状态。
新建分支里 createdBy:默认当前登录人;若同时带有 externalId 与 externalLastModifyTime,则允许使用入参里的创建人(服务「由同步逻辑写库」的场景)。
// 默认:创建人=当前登录人;但对 CCC 同步任务(externalLastModifyTime 非空),允许由同步逻辑显式指定创建人
Integer createdBy = userId;
if (plan.getCreatedBy() != null
&& StringUtils.isNotBlank(plan.getExternalId())
&& plan.getExternalLastModifyTime() != null) {
createdBy = plan.getCreatedBy();
}
plan.setCreatedBy(createdBy);
planDao.insert(plan);
5. 显式状态机
static {
stateList.add(PlanStateDefine.builder()
.currentState(PlanState.CREATED).action(PlanAction.START).nextState(PlanState.HANDLING).build());
stateList.add(PlanStateDefine.builder()
.currentState(PlanState.HANDLING).action(PlanAction.END).nextState(PlanState.DONE).build());
stateList.add(PlanStateDefine.builder()
.currentState(PlanState.HANDLING).action(PlanAction.OVERDUE).nextState(PlanState.OVERDUE).build());
stateList.add(PlanStateDefine.builder()
.currentState(PlanState.OVERDUE).action(PlanAction.END).nextState(PlanState.DONE).build());
}
非法 (当前状态, 动作) 会抛业务异常。人工结束时:读库中当前 status,调用 getNextState(..., PlanAction.END),写 endRealTime 后更新。
public OperationInfo<Object> endPlan(Plan plan) {
Integer planId = plan.getId();
if (planId == null) {
return OperationInfo.failure(SpringUtil.getMessage(I18nMessageKey.PARAM_ERROR));
}
Plan planDb = planDao.findById(planId);
if (planDb == null) {
return OperationInfo.failure();
}
Integer nextState = PlanStateMachine.getNextState(planDb.getStatus(), PlanAction.END);
Plan newPlan = new Plan();
newPlan.setId(planId);
newPlan.setStatus(nextState);
newPlan.setScore(plan.getScore());
newPlan.setFeedback(plan.getFeedback());
newPlan.setEndRealTime(new Date());
planDao.update(newPlan);
return OperationInfo.success();
}
6. 自动流转:定时任务 + SQL
/**
* 每一分钟
* 处理创建状态计划任务任务负载状态流转为进行中
*/
@Scheduled(cron = "0 */1 * * * *")
public void startPlanTaskLoad() {
RLock lock = redissonClient.getLock("MES_PLAN_TASK_LOAD_START_LOCK");
try {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
List<Plan> planList = planDao.findNeedStartPlanList();
if (CollectionUtils.isNotEmpty(planList)) {
planList.forEach(plan -> {
Integer nextState = PlanStateMachine.getNextState(plan.getStatus(), PlanAction.START);
planDao.updateStatus(plan.getId(), nextState);
});
}
List<Plan> overdueList = planDao.findNeedOverDuelistPlanList();
if (CollectionUtils.isNotEmpty(overdueList)) {
overdueList.forEach(overdue -> {
Integer nextState = PlanStateMachine.getNextState(overdue.getStatus(), PlanAction.OVERDUE);
planDao.updateStatus(overdue.getId(), nextState);
});
}
// 恢复已逾期但结束时间已延后的计划任务
List<Plan> needResumeList = planDao.findNeedResumePlanList();
if (CollectionUtils.isNotEmpty(needResumeList)) {
needResumeList.forEach(p -> {
planDao.updateStatus(p.getId(), 1); // 直接设置为进行中状态
});
}
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
log.error("计划任务开始任务异常", e);
Thread.currentThread().interrupt();
}
}
与上述三段 Java 对应的 Mapper 查询(与工程内 MyBatis XML 一致,resultType 指向 Plan 实体类):
<select id="findNeedStartPlanList" resultMap="PlanMap">
select id,
created_by createdBy,
created_time createdTime,
company_id companyId,
start_date startDate,
end_date endDate,
status,
title,
deliver,
executor_ids executorIds,
end_real_time endRealTime,
ignore_weekend ignoreWeekend
from es_plan
where status = 0
and start_date <= now()
</select>
<select id="findNeedOverDuelistPlanList" resultType="com.enmo.enmo_support.workbench.model.Plan">
select id,
created_by createdBy,
created_time createdTime,
company_id companyId,
start_date startDate,
end_date endDate,
status,
title,
deliver,
executor_ids executorIds,
end_real_time endRealTime,
ignore_weekend ignoreWeekend
from es_plan
where status = 1
and end_date <= now()
</select>
<select id="findNeedResumePlanList" resultType="com.enmo.enmo_support.workbench.model.Plan">
select id,
created_by createdBy,
created_time createdTime,
company_id companyId,
start_date startDate,
end_date endDate,
status,
title,
deliver,
executor_ids executorIds,
contract_id contractId,
check_type checkType,
acc_id accId,
ignore_weekend ignoreWeekend,
end_real_time endRealTime,
score,
feedback,
start_remind_sent startRemindSent,
overdue_remind_sent overdueRemindSent,
last_daily_remind_date lastDailyRemindDate,
external_id externalId,
external_last_modify_time externalLastModifyTime
from es_plan
WHERE status = 3
AND end_date > NOW()
</select>
另有 findWillStartInFifteenMinutes:status = 0、未发过开始提醒、且 start_date 落在未来约 1~15 分钟内——多用于提醒,与上述 status 批处理解耦。
7. 结束与删除
删除保护(实施计划不允许物理删):
// 实施计划(external_id不为空)不允许删除
if (StringUtils.isNotBlank(plan.getExternalId())) {
return OperationInfo.failure("实施计划不允许删除");
}
8. 执行人变更与通知(简述)
更新时若带执行人列表,常见实现是:执行人 id 未变则只更新工时、备注等,不重复发通知;更换执行人则删旧插新并走通知逻辑(失败仅记日志,不阻写库)。
9. 小结:阅读顺序与扩展
- 状态枚举 +
PlanStateMachine - Mapper 中
findNeedStart*/findNeedOverDue*/findNeedResume* - 定时任务:锁、顺序、
updateStatus PlanServiceImpl.save/endPlan/deletePlan- 外部实施计划 Controller + 合同同步里组装
Plan的分支
若要增加新状态(如「暂停」):扩展枚举与状态机边,并审计所有直接 updateStatus 的路径(尤其是逾期恢复硬编码为「进行中」的那段)。
10. 运维与排障提示
- 权限:后台直存接口与「外部实施计划」接口的角色范围可能不同,手册里分开写清。
- 时间:
start_date/end_date与now()比较依赖数据库会话时区,跨环境问题时先对齐 DB 与 JVM。 - 多实例:分布式锁不可用会导致同一分钟重复推进状态,需在部署层保证锁中间件可用。