领域模型——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),好处是关系型、外键约束能保证数据完整性,坏处是:
- N+1 查询:查一个工单下所有工序的候选设备,要先查 task 列表(1 次),再逐个查每个 task 的关联表(N 次),20 道工序就是 21 次 SQL。
- 写入麻烦:创建一个 20 道工序的工单,每道工序要 INSERT 自己的关联行,20 次 INSERT。
- 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);
}
注意两个关键点:
- 先算产能系数换算后的实际工时(standardMinutes / capacityFactor),用
Math.ceil向上取整避免尾数丢失。 - 再交给
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 系统"为什么能解对"的真正秘密。
