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

下文代码摘录自 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:计划与执行人的多行关系;保存时会维护并可能触发通知。

状态枚举(节选):

java 复制代码
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(强角色)

java 复制代码
@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,禁止静默创建;已有一条则走更新。

java 复制代码
@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 设为第一个执行人

java 复制代码
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 不一致,仍继续刷新,避免外部时间戳不变、负责人已变时本地永远不更新。

java 复制代码
// 先解析项目经理与本地合同: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 与校验类型之后:

java 复制代码
//计划任务的状态变更: 根据时间触发和结束任务接口
plan.setStatus(null);
plan.setEndRealTime(null);
//更新时只处理计划任务主体表单
planDao.update(plan);

这样表单保存标题、执行人、日期等时,不会覆盖定时任务刚改过的状态。

新建分支createdBy :默认当前登录人;若同时带有 externalIdexternalLastModifyTime,则允许使用入参里的创建人(服务「由同步逻辑写库」的场景)。

java 复制代码
// 默认:创建人=当前登录人;但对 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. 显式状态机

java 复制代码
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 后更新。

java 复制代码
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

java 复制代码
/**
 * 每一分钟
 * 处理创建状态计划任务任务负载状态流转为进行中
 */
@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 实体类):

xml 复制代码
<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. 结束与删除

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

java 复制代码
// 实施计划(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
  • 多实例:分布式锁不可用会导致同一分钟重复推进状态,需在部署层保证锁中间件可用。

相关推荐
llz_1122 小时前
web-第二次课后作业
前端·后端·web
红尘散仙8 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记9 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆9 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪10 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball61610 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_25183645710 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao11 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒12 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
ayqy贾杰13 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理