领域模型——6 张表讲清 APS

第 2 / 10 章
领域模型——6 张表讲清 APS

一、为什么 APS 比 SCADA 多两张表

SCADA 的领域模型是 5 张表:device / point / collect_task / alarm_rule / alarm_event。APS 多了两张——aps_calendar(班次日历)和 aps_mold(模具/工装)。这不是拍脑袋加的,是业务本质决定的:

SCADA 管的是"连续 24 小时开机的设备"。 PLC 挂在车间里,一天不关机,采集任务每秒跑一次,TDengine 里时序数据按 1 秒间隔落盘。设备有没有在上班?SCADA 不关心——PLC 24 小时都在跑。你要说"今天是国庆,这台 PLC 关掉了",那在 SCADA 里你手动把 collect_task.enabled 设成 false 就行,不需要一张专门的日历表来管理"哪些时段该采集、哪些不该"。

APS 管的是"跟着班次日历的资源"。 注塑机不是 24 小时开机,它两班倒,白班 08:00-20:00,夜班 20:00-08:00,周六只开白班,周日全厂休息,国庆放 7 天假。这些"可用性窗口"不是简单的开关,而是复杂的时间规则——Timefold 求解器需要知道"这台机器在 10 月 1 日到 7 日不可用",才能在那几天不排产。这就是 aps_calendar 存在的意义。

模具更简单:SCADA 没有"模具"这个概念,因为传感器只管自己采集数据,不需要另一台"设备"来配合。但 APS 排产,很多工序需要特定的模具,模具数量有限——MOLD-001 只有 2 套,同一时间最多有 2 个工单能用到它。Timefold 求解器要知道这个约束,才能算出"3 个工单都想用 MOLD-001 时,把其中一个换到 MOLD-002"。这就是 aps_mold 的来源。

6 张表 DDL 全部在 yudao-module-aps/src/main/resources/sql/aps-init.sql 里,建表脚本 + 种子数据(3 台设备 + 1 个日历 + 1 个模具),一条命令跑起来就能本地 Demo。所有表都带 tenant_id、creator、create_time、updater、update_time、deleted 这套芋道标准字段——多租户拦截器自动注入 tenant_id,软删除自动处理 deleted 字段。

二、ER 极简图(文字版)

不画密密麻麻的 ER 图,直接文字描述关系:

aps_work_order (工单)
    │ 1
    │
    │ N
aps_task (工序) ────── N:1 ────── aps_resource (设备)
    │                                    │
    │ N                                  │ 1
    │                                    │
    │ N                                  │ N
aps_mold (模具)                        aps_calendar (班次日历)
    │                                    │
    │ N                                  │ N
    │                                    │
aps_schedule_plan (排产快照) ── N ── aps_task (通过 schedule_plan_id 关联)

核心关系链:工单 → 工序 → 设备/模具。工序是连接一切的枢纽——它挂在工单下面(work_order_id 外键),可以分配到候选设备(candidate_resource_ids JSONB),可以需要模具(mold_id),求解后回填排产结果(planned_machine_id / planned_start_time / planned_end_time),最后关联到某次排产快照(schedule_plan_id)。

三、逐张表讲解(每张表都有设计决策和踩坑)

aps_work_order:工单,生产计划的源头

CREATE TABLE aps_work_order (
    id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id      BIGINT       NOT NULL,
    code           VARCHAR(64)  NOT NULL,
    product_code   VARCHAR(64)  NOT NULL,
    product_name   VARCHAR(128) NOT NULL,
    quantity       INTEGER      NOT NULL,
    priority       SMALLINT     NOT NULL DEFAULT 5,  -- 1=最高, 10=最低
    demand_date    DATE         NOT NULL,              -- 需求完工日期
    status         SMALLINT     NOT NULL DEFAULT 1,   -- 1=待排产 2=已排产 ...
    ...
);

ApsWorkOrderDO 对应这个表。几个关键字段的设计决策:

priority 越小越优先。 1=最高优先级,10=最低。这个编码约定直接影响 Timefold 的延迟罚分权重——在 ApsScheduleConstraintProvider.penalizeTardiness() 里:

// ApsScheduleConstraintProvider.java 第 133 行
int weight = 11 - t.getPriority(); // 优先级 1 → weight 10, 优先级 10 → weight 1
score.addSoft((int) delayDays * weight * 100);

priority=1 的工单延迟一天罚 1000 分,priority=10 的只罚 100 分。Timefold 在 Local Search 阶段会自动优先保护高优工单——因为把高优工单延迟一天比把低优工单延迟一天罚分多得多,求解器会尝试把高优工单往前挪、低优工单往后放,直到找到整体罚分最低的方案。这比我第一版写死的"先排高优再排低优"的启发式策略好太多——启发式是硬性规则,Timefold 是加权软约束,可以自然处理"两个 priority=1 的工单同时 deadline 在昨天"的复杂场景。

demand_date 是软约束,不是硬约束。 如果有 5 个工单的 demand_date 都在昨天,硬约束会让求解器报告"不可行";软约束让求解器尝试最小化总延迟,可能把前 3 个赶出来、后 2 个延迟一天,比全不排强。这是 APS 和传统 ERP 的本质区别——ERP 是"我给你一个完成期限",APS 是"我告诉你在当前资源约束下你最早能什么时候完成"。

status 流转由求解器自动推进。 在 ScheduleSolverServiceImpl.solve() 第 155-161 行:

for (ApsWorkOrderDO wo : workOrders) {
    boolean allScheduled = allTasks.stream()
            .filter(t -> t.getWorkOrderId().equals(wo.getId()))
            .allMatch(t -> t.getStatus() != null && t.getStatus() >= ApsTaskStatus.SCHEDULED.getCode());
    if (allScheduled) {
        workOrderService.updateStatus(wo.getId(), ApsWorkOrderStatus.SCHEDULED.getCode());
    }
}

工单状态不是手动改的——当这个工单下所有工序都排产完成后,Solver 自动把工单从"待排产"推到"已排产"。这样避免了工单和工序状态不一致(工单说"已排产"但有两道工序没排)。

aps_task:工序,Timefold 的 PlanningEntity 直接映射

这是整个 APS 系统最核心的表,也是改动最多的。最终版本的 DDL:

CREATE TABLE aps_task (
    id                      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    work_order_id           BIGINT       NOT NULL REFERENCES aps_work_order (id) ON DELETE CASCADE,
    seq_no                  INTEGER      NOT NULL,                -- 同一工单内工序序号递增
    operation_code          VARCHAR(64)  NOT NULL,                -- OP-10 / OP-20 / OP-30
    operation_name          VARCHAR(128) NOT NULL,
    standard_minutes        INTEGER      NOT NULL,                -- 标准工时(分钟)
    candidate_resource_ids  JSONB       NOT NULL DEFAULT '[]',    -- ⭐ 可选设备列表
    mold_id                 BIGINT,                               -- ⭐ 所需模具
    mold_qty_needed         INTEGER      NOT NULL DEFAULT 0,
    setup_minutes           INTEGER      NOT NULL DEFAULT 0,      -- ⭐ 换型时间
    -- 求解后回填的结果(Timefold 解出来之前全是 NULL)
    planned_machine_id      BIGINT,
    planned_start_time      TIMESTAMPTZ,
    planned_end_time        TIMESTAMPTZ,
    version                 INTEGER      NOT NULL DEFAULT 0,       -- ⭐ 乐观锁
    locked                  BOOLEAN      NOT NULL DEFAULT FALSE,    -- ⭐ 锁定=不可重排
    schedule_plan_id        BIGINT,
    status                  SMALLINT     NOT NULL DEFAULT 1,       -- 1=待排产 2=已排产 3=生产中 4=已完成
    INDEX idx_aps_task_plan (schedule_plan_id, planned_start_time),
    INDEX idx_aps_task_machine (planned_machine_id, planned_start_time)
);

candidate_resource_ids 用 JSONB 还是关联表?——踩了坑才选 JSONB。 这是 aps_task 表上最纠结的设计决策。第一版我用了关联表 aps_task_resource(task_id, resource_id),好处是关系型、外键约束能保证数据完整性,坏处是:

  1. N+1 查询:查一个工单下所有工序的候选设备,要先查 task 列表(1 次),再逐个查每个 task 的关联表(N 次),20 道工序就是 21 次 SQL。
  2. 写入麻烦:创建一个 20 道工序的工单,每道工序要 INSERT 自己的关联行,20 次 INSERT。
  3. Timefold 适配层还要做 Map 聚合:从关联表查出来是一堆零散行,要在 Service 层重新按 task_id 聚合成 List 才能转成 PlanningTask 的 candidateMachines。

改成 JSONB 后,DDL 一行搞定,MyBatis-Plus 的 JacksonTypeHandler 自动做序列化:

// ApsTaskDO.java 第 49-50 行
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> candidateResourceIds;

数据库里存的是 [1,2,5] 这样的 JSON 数组,MyBatis 自动把它解析成 List<Long>,Service 层直接用,不用额外查询。唯一的缺点是不能在 SQL 层做外键约束(JSONB 里的 ID 不能直接 REFERENCES aps_resource.id),但这个缺点在排产场景下完全可接受——如果 candidate_resource_ids 里有一个资源 ID 不存在,PlanningTask 转换时会跳过(filter(Objects::nonNull)),最多那道工序排不进去,不会导致整个排产崩溃。

planned_machine_id / planned_start_time / planned_end_time 是"输出字段",不是"输入字段"。 这三个字段在 Timefold 求解之前全是 NULL——DO 里初始化成 null,ApsPlanningTask 里也初始化成 null(除了 locked 的任务用 DB 里的预分配值)。Timefold 求解完后,ScheduleSolverServiceImpl.solve() 第 146-148 行回填:

task.setPlannedMachineId(a.machineId());
task.setPlannedStartTime(a.startTime());
task.setPlannedEndTime(a.endTime());

这个设计有个好处:排产结果可以反复重置。下次插单重排时,把这批 task 的这三个字段先清空成 NULL(或者直接读 schedule_plan_id 过滤掉旧计划的 task),Timefold 重新求解,回填新的一组结果。老的排产快照(aps_schedule_plan)还在,追溯历史很方便。

version 乐观锁字段。 默认值 0,每次 updateById 自动 +1。多人同时改同一条 task 时,最后提交者会触发 OptimisticLockException。这个字段在 ApsTaskDO 里有,但 @Version 注解我还没加在代码里——写连载的时候补进去就好,MyBatis-Plus 的乐观锁是一行注解的事。

locked 字段是"插单重排的关键开关"。 设成 true 的 task 在求解时被排除(unlockedTasks = allTasks.stream().filter(t -> !Boolean.TRUE.equals(t.getLocked())).toList()),Timefold 完全跳过它的变量搜索。这就是插单时"已开工任务不动"的实现机制。

aps_resource:设备/产线,Timefold 里叫 Machine(ProblemFact)

CREATE TABLE aps_resource (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code        VARCHAR(64)  NOT NULL,             -- LINE-01 / CNC-03
    name        VARCHAR(128) NOT NULL,
    resource_type SMALLINT   NOT NULL DEFAULT 1,  -- 1=产线 2=设备 3=班组 4=工位
    capacity    DECIMAL(4,2) NOT NULL DEFAULT 1.00, -- ⭐ 产能系数
    calendar_id BIGINT       NOT NULL,               -- ⭐ 引用日历
    status      SMALLINT     NOT NULL DEFAULT 1,    -- 1=可用 2=维护 9=停用
    UNIQUE (tenant_id, code, deleted)
);

capacity(产能系数)是整个 APS 最精巧的设计。 它解决了"不同机器干活速度不一样"的问题。注塑车间里,同样 60 分钟的标准工序,CNC-01(capacity=1.2)实际只需要 50 分钟(Math.ceil(60 / 1.2) = 50),而 CNC-02(capacity=0.75)可能需要 80 分钟。这个系数不在 ConstraintProvider 里处理,而在 PlanningMachine.computeEndTime() 里处理:

// PlanningMachine.java 第 44-47 行
public LocalDateTime computeEndTime(LocalDateTime start, int standardMinutes) {
    int actualMinutes = (int) Math.ceil(standardMinutes / capacityFactor.doubleValue());
    return CalendarUtil.computeEndTime(calendar, start, actualMinutes);
}

注意两个关键点:

  1. 先算产能系数换算后的实际工时(standardMinutes / capacityFactor),用 Math.ceil 向上取整避免尾数丢失。
  2. 再交给 CalendarUtil.computeEndTime 跳日历。产能系数和日历跳段解耦——先算工时,再跳日历,逻辑清晰好维护。如果反过来先跳日历再算工时,那跨天场景下两个逻辑会纠缠在一起,很难调试。

calendar_id 是外键,但用的是普通外键(不是 FK constraint)。 因为一个日历可以被多台设备引用(两班倒的 12 台注塑机都用同一个日历),calendar_id 设了 NOT NULL 保证每台设备必须有一个日历。如果想停机维护设备,改 status=2 就行,不用改 calendar。

aps_calendar:班次日历,跟着班次走

CREATE TABLE aps_calendar (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        VARCHAR(128) NOT NULL,          -- 两班倒-白班 / 三班制
    shifts      JSONB        NOT NULL DEFAULT '[]', -- ⭐ 每天班次
    holiday_dates DATE[]     NOT NULL DEFAULT '{}', -- ⭐ 节假日数组
    exception_ranges JSONB   NOT NULL DEFAULT '[]', -- 特殊调整(换班/加班/停机)
);

shifts JSON 是核心。 一个日历可以定义 7 天的班次:

[
  {"dayOfWeek":"MONDAY","start":"08:00","end":"20:00","pauseStart":"12:00","pauseEnd":"13:00"},
  {"dayOfWeek":"TUESDAY","start":"08:00","end":"20:00"},
  {"dayOfWeek":"SATURDAY","start":"08:00","end":"12:00"},
  {"dayOfWeek":"SUNDAY","start":"00:00","end":"00:00"}
]

ApsCalendarDO 的内部类 Shift 对应这个结构:

// ApsCalendarDO.java 第 60-70 行
@Data
public static class Shift implements Serializable {
    private String dayOfWeek;   // MONDAY / TUESDAY / ...
    private String start;       // HH:mm 格式
    private String end;         // HH:mm 格式
    private String pauseStart;  // 午休开始,可空
    private String pauseEnd;    // 午休结束,可空
}

跨天班次反转是第一个踩的坑。 晚班 20:00-08:00,start > end。CalendarUtil.isWorkingTime() 必须正确处理:

// CalendarUtil.java 第 74-78 行
if (start.isAfter(end)) {
    // 跨天:dt >= start || dt < end
    inRange = !dt.toLocalTime().isBefore(start) || dt.toLocalTime().isBefore(end);
} else {
    // 不跨天:start <= dt < end
    inRange = !dt.toLocalTime().isBefore(start) && dt.toLocalTime().isBefore(end);
}

第一版我只写了不跨天的判断(start <= time < end),结果晚班排产全部失效——午夜 00:00 属于晚班,但 00:00 >= 20:00 && 00:00 < 08:00 的逻辑在 start=20:00 > end=08:00 的情况下直接返回 false。加了反转判断才修对。

holiday_dates 是 PostgreSQL 的 Date[] 类型。 不能跨 DB(MySQL 没有原生的数组类型),但既然整个 APS 项目用的是 PostgreSQL(SCADA 也是),那就用。CalendarUtil.computeEndTime() 会跳过 holiday_dates 里的日期——读到 holiday 直接 t = t.plusDays(1) 继续找下一天。

aps_mold:模具/工装,数量有限

CREATE TABLE aps_mold (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code        VARCHAR(64)  NOT NULL,
    name        VARCHAR(128) NOT NULL,
    quantity    INTEGER      NOT NULL DEFAULT 1,   -- 可用模具总数
    available_calendar_id BIGINT,                   -- 模具自己的日历(可空)
    status      SMALLINT     NOT NULL DEFAULT 1,   -- 1=可用 2=维修中 9=报废
);

ApsMoldDO 很简单,就 quantity 一个关键字段。Timefold 不直接建模模具——Timefold 的原生约束里没有"同资源在同时间窗口不能超量"这个硬约束,我自己在 penalizeMoldQuota() 里实现:

// ApsScheduleConstraintProvider.java 第 68-91 行
for (ApsPlanningTask a : solution.getTaskList()) {
    if (a.getMoldId() == null || a.getStartTime() == null) continue;
    PlanningMold mold = solution.getMoldList().stream()
            .filter(m -> m.getId().equals(a.getMoldId())).findFirst().orElse(null);
    if (mold == null) continue;

    int usage = a.getMoldQtyNeeded();
    for (ApsPlanningTask b : solution.getTaskList()) {
        if (a == b) continue;
        if (a.getMoldId().equals(b.getMoldId()) && b.getStartTime() != null
                && ConflictDetector.conflicts(
                        a.getStartTime(), a.getEndTime(),
                        b.getStartTime(), b.getEndTime())) {
            usage += b.getMoldQtyNeeded();
        }
    }
    if (usage > mold.getQuantity()) {
        score.addHard(usage - mold.getQuantity());
    }
}

简化版 O(N²) 遍历——对每个用到模具的 Task,检查同时间窗口内还有没有别的 Task 也用了同一个模具,累加数量。超出 quantity 的部分罚 hard score(每超 1 个罚 1 分)。Timefold 求解器会自动避开这些解,因为 hard penalty 要先降到 0 再谈软约束优化。

aps_schedule_plan:排产快照,每次求解留个痕

CREATE TABLE aps_schedule_plan (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        VARCHAR(128) NOT NULL,                    -- 2026-10-13 早班排产
    schedule_date DATE       NOT NULL,
    solver_type SMALLINT     NOT NULL DEFAULT 2,          -- 1=启发式 2=Timefold
    hard_penalty  INTEGER    NOT NULL DEFAULT 0,
    soft_penalty  INTEGER    NOT NULL DEFAULT 0,
    total_tasks INTEGER      NOT NULL DEFAULT 0,
    locked_tasks INTEGER     NOT NULL DEFAULT 0,
);

这个表很关键——它是排产的"快照",也是甘特图的"入口"。 甘特图 Controller 只展示某个 plan 的排产结果(GET /api/admin/aps/gantt/{planId}),插单重排从最新的 plan 里找出已排产工单作为重排基础。

hard_penalty 和 soft_penalty 是 Timefold 评分结果的存根。HardSoftScore 的规则是 hard * 1_000_000 + soft,hard 永远优先满足。hard_penalty == 0 意味着硬约束全满足——这是排产结果可执行的前提。soft_penalty 越小越好,反映 Makespan/换型/延迟等软约束的优化程度。

Solver 写入快照的代码在 ScheduleSolverServiceImpl.solve() 第 129-138 行:

ApsSchedulePlanDO plan = new ApsSchedulePlanDO();
plan.setTenantId(workOrders.get(0).getTenantId());
plan.setName(planName);
plan.setScheduleDate(LocalDate.now());
plan.setSolverType(ApsSolverType.TIMEFOLD.getCode());
plan.setHardPenalty(result.hardPenalty());
plan.setSoftPenalty(result.softPenalty());
plan.setTotalTasks(result.taskCount());
plan.setLockedTasks((int) allTasks.stream().filter(t -> Boolean.TRUE.equals(t.getLocked())).count());
planMapper.insert(plan);

四、ER 极简图(补充理解)

6 张表的关系链再梳理一遍:

aps_work_order (工单源头)
    │ 1:N → aps_task (工序,挂工单)
    │                    │ N:1 → aps_resource (设备)
    │                    │              │ N:1 → aps_calendar (日历)
    │                    │ N:1 → aps_mold (模具)
    │
aps_schedule_plan (快照) ── N:1 → aps_task (通过 schedule_plan_id 关联)

一条完整的排产数据链路:ERP 下发 20 条工单 → APS 生成 60 条工序(平均每工单 3 道)→ Timefold 求解把每条工序分配到设备 + 填好时间 → 生成一条快照 → 甘特图展示。

五、踩坑总结

第一版建模犯过三个错误:

坑一:candidate_resource_ids 用关联表。N+1 查询导致排产前要花 2 秒加载 60 道工序的候选设备列表,改成 JSONB 后 200ms 搞定。代价是放弃了 SQL 层的外键约束,但这个代价可接受——脏数据最多导致某道工序排不进去,不会导致排产崩溃。

坑二:capacity 字段名叫 capacity 但含义是产能系数。ApsResourceDO 里字段叫 capacity,但实际是 planningMachine.capacityFactor。命名要统一——要么都叫 capacity,要么都叫 capacityFactor。我选了在 DO 层叫 capacity(和行业习惯一致),在 Planning 层叫 capacityFactor(更精确)。

坑三:aps_task 和 aps_schedule_plan 之间没有外键约束。schedule_plan_id 只是一个普通的 BIGINT,没有 REFERENCES aps_schedule_plan(id)。原因是 MyBatis-Plus 的软删除机制——aps_schedule_plan 的 deleted 字段如果是 true,它的 id 不应该被引用,但 PostgreSQL 的 FK constraint 不看 soft delete 只看物理删除。所以全部用普通列,靠应用层保证引用完整性。

六、商用化检查清单(领域模型版)

  • candidate_resource_ids 的 JSONB 解析验证过吗?MyBatis-Plus 的 JacksonTypeHandler 在 INSERT 时能把 List<Long> 正确序列化成 JSON,SELECT 时能反序列化回来吗?
  • 日历跨天班次和午休跳过测试过吗?写个单元测试,start="20:00" end="08:00",查 midnight 时刻的 isWorkingTime,应该返回 true。
  • 产能系数计算正确吗?capacity=1.2 时 60 分钟标准工时应产出 50 分钟实际工时(向上取整),capacity=0.75 时产出 80 分钟。
  • holiday_dates 类型是 PostgreSQL 的 Date[],如果未来要跨 MySQL,记得改成 VARCHAR[] 或关联表。
  • schedule_plan 的 hard_penalty/soft_penalty 有没有校验逻辑?前端能不能根据 hard_penalty > 0 阻止标记"已执行"?

下一章讲约束建模——7 条约束怎么写进 ConstraintProvider、HardSoftScore 怎么用、日历跳过和换型时间怎么处理,以及硬约束违反时 Timefold 会怎么报错。那是整个 APS 系统"为什么能解对"的真正秘密。