MES 实施计划从新建到自动流转:三条入口、一套存储、两类驱动

22 阅读7分钟

下文代码摘录自 Spring Boot + MyBatis 风格的后端工程(计划域与外部订单系统对接)。为便于对外发布,与具体租户相关的整型缺省值(如未传组织时的默认客户组织主键)在片段中以 DEFAULT_COMPANY_ID 代替,你在自有仓库中按常量或配置项对照即可;其余逻辑与真实实现一致。


1. 先厘清概念:同一张表里的两种「计划」

业务上常把「在 MES 里排的计划」和「从外部系统同步的实施计划」都叫计划,工程上落在同一套持久化模型里,靠字段区分:

概念典型标识说明
MES 计划任务external_id 为空满足权限时可删除。
外部实施计划external_id 非空(对应外部的 SSRWID删除接口直接拒绝,避免与外部主数据脱节。

列表查询常用 planType 区分:plan(仅 MES)、implementation(仅带外部 ID 的实施计划)、all


2. 数据模型与状态码

  • es_plan:标题、起止时间、statusacc_idcontract_idexternal_idexternal_last_modify_timecreated_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 路径二:外部实施计划「创建」接口(节选)

校验 SSRWIDaccId,按「子项 + 外部计划 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:默认当前登录人;若同时带有 externalIdexternalLastModifyTime,则允许使用入参里的创建人(服务「由同步逻辑写库」的场景)。

// 默认:创建人=当前登录人;但对 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 &lt;= 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 &lt;= 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>

另有 findWillStartInFifteenMinutesstatus = 0、未发过开始提醒、且 start_date 落在未来约 1~15 分钟内——多用于提醒,与上述 status 批处理解耦。


7. 结束与删除

删除保护(实施计划不允许物理删):

// 实施计划(external_id不为空)不允许删除
if (StringUtils.isNotBlank(plan.getExternalId())) {
    return OperationInfo.failure("实施计划不允许删除");
}

8. 执行人变更与通知(简述)

更新时若带执行人列表,常见实现是:执行人 id 未变则只更新工时、备注等,不重复发通知更换执行人则删旧插新并走通知逻辑(失败仅记日志,不阻写库)。


9. 小结:阅读顺序与扩展

  1. 状态枚举 + PlanStateMachine
  2. Mapper 中 findNeedStart* / findNeedOverDue* / findNeedResume*
  3. 定时任务:锁、顺序、updateStatus
  4. PlanServiceImpl.save / endPlan / deletePlan
  5. 外部实施计划 Controller + 合同同步里组装 Plan 的分支

若要增加新状态(如「暂停」):扩展枚举与状态机边,并审计所有直接 updateStatus 的路径(尤其是逾期恢复硬编码为「进行中」的那段)。


10. 运维与排障提示

  • 权限:后台直存接口与「外部实施计划」接口的角色范围可能不同,手册里分开写清。
  • 时间start_date / end_datenow() 比较依赖数据库会话时区,跨环境问题时先对齐 DB 与 JVM
  • 多实例:分布式锁不可用会导致同一分钟重复推进状态,需在部署层保证锁中间件可用。