领域模型——14 类 Item 讲清 PLM

一、领域建模的两种极端
PLM 的领域建模难在哪?难在对象类型多、类型差异大、跨类型查询还要保留。
我刚入行的时候,做一个 PLM 模块的设计,团队里吵了 3 天:
- A 派:所有对象塞一张表
items,加 200 列。简单粗暴。 - B 派:items 只存 id,所有属性走 EAV(item_id + attr_name + attr_value)。
- C 派:每类型一张表,parts / documents / change_orders 完全独立。
3 个方案都不完美。A 派导致 schema 越来越胖,加个字段要改所有 SQL;B 派查询要 JOIN N 次,性能炸;C 派跨类型查询要 UNION ALL,新类型要改 schema。
Cascadia 用了一个折中方案——Two-Table Pattern——这一章我就拆这个模式。
二、组织层级:Organization → Program → Design → Items
在讲类型表之前,先讲层级。Cascadia 把组织分成 4 层:
Organization(组织)
└── Program(程序,权限边界)
└── Design(版本容器)
└── Items(具体对象)
为什么需要这 4 层?
- Organization:公司级。一个组织有多个 Program。
- Program:权限边界。一个客户、一个产品线、一个保密项目——都是 Program。Program 内的工程师只能看到本 Program 的对象,跨 Program 看不到。这一层解决"客户 A 不能看到客户 B 的设计"。
- Design:版本容器。一个 Design 是"某个产品的所有版本的所有分支"。比如 TDJ-25 机械臂就是一个 Design,里面有 main 分支 + N 个 eco 分支。
- Items:具体对象。Part、Document、ChangeOrder 这些都在 Design 下面。
注意:Design 不是产品型号。同一个产品型号可以有多个 Design(按客户、按变体、按时间分),同一 Design 内部又能有多个分支版本。这是 PLM 比 PDM 多culated 的一层抽象。
DDL 在 V1__init_plm.sql 里:
CREATE TABLE IF NOT EXISTS plm_designs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(128) NOT NULL UNIQUE,
program_id BIGINT NOT NULL DEFAULT 0,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
注意没有 organization_id——因为组织是 SaaS 多租ews,由 tenant_id 在所有表上挂载(Cascadia 原版用 Row-Level Security,yudao 用 yudao-spring-boot-starter-tenant 自动注入)。所有表的 tenant_id 字段是 yudao 框架自动加的,业务代码不感知。
三、Two-Table Pattern:基表 + 类型专属表
3.1 模式定义
Two-Table Pattern 是这样:
- 基表
plm_items:所有 14 类对象共用,存共有字段(master_id / item_number / revision / lifecycle_state / branch_id / design_id / title / description / created_at / updated_at / deleted / tenant_id)。 - 类型专属表
plm_<type>:只存类型特有字段。一对一关联到 items.id。
plm_items (基表,所有类型共用)
│
├── plm_parts (Part: material / weight / cost / manufacturer_part_number)
├── plm_documents (Document: file_path / sha256 / size_bytes / category)
├── plm_change_orders (ChangeOrder: eco_number / reason / scope / scope_locked)
├── plm_requirements (Requirement: requirement_text / verification_method)
├── plm_work_orders (WorkOrder: quantity / due_date / priority)
├── plm_physical_parts (PhysicalPart: serial_number / manufactured_at)
├── plm_software (Software: version / build_hash)
├── plm_tools (Tool: tool_type / calibrated_at)
├── plm_work_instructions (WorkInstruction: step_count / pdf_path)
├── plm_test_plans (TestPlan: test_type / pass_criteria)
├── plm_test_cases (TestCase: expected_result)
├── plm_system_elements (SystemElement: parent_element_id)
├── plm_issues (Issue: severity / status)
└── plm_tasks (Task: assignee_id / due_date)
3.2 关键好处
- 跨类型查询走基表:要查"某 Design 下所有 Released 的对象",只 SELECT plm_items WHERE lifecycle_state='released',不用 UNION ALL。
- 类型字段查询走专属表 JOIN:要查"重量 > 5kg 的 Part",SELECT plm_items JOIN plm_parts ON plm_parts.item_id = plm_items.id WHERE plm_parts.weight > 5。
- 加新类型零侵入基表:新增一个 Software 类型,只要建 plm_software 表 + 加枚举 + 注册 ItemTypeRegistry——基表 schema 完全不动。
- 类型差异显式:material 是 Part 独有,cost 是 Part 独有——这些字段在专属表里,不会被别的类型误用。
3.3 关键 SQL
plm_items 的核心约束是唯一约束 (item_number, revision, design_id, item_type):
CREATE TABLE IF NOT EXISTS plm_items (
id BIGSERIAL PRIMARY KEY,
master_id BIGINT,
item_number VARCHAR(64) NOT NULL,
item_type VARCHAR(32) NOT NULL,
design_id BIGINT NOT NULL REFERENCES plm_designs(id),
branch_id BIGINT NOT NULL REFERENCES plm_branches(id),
revision VARCHAR(16) NOT NULL,
title VARCHAR(255),
description TEXT,
lifecycle_state VARCHAR(32) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id BIGINT NOT NULL DEFAULT 0,
UNIQUE(item_number, revision, design_id, item_type)
);
这个唯一约束是 RevisionService 并发分配字母 revision 时的兜底——如果两个并发事务同时算出下一个 revision 是 "B",写入时第二个会被 PG 拒绝,业务层重试。
类型专属表以 plm_parts 为例:
CREATE TABLE IF NOT EXISTS plm_parts (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES plm_items(id),
material VARCHAR(64),
weight DECIMAL(10,3),
manufacturer_part_number VARCHAR(128),
cost DECIMAL(10,2),
unit_of_measure VARCHAR(16),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
注意 item_id 是 FK 到 plm_items.id,但是没有 UNIQUE 约束——这是因为同一个 item_id 可能在不同 revision 下出现多行(Cascadia 0.5 之前是这样设计的),新版改成了一对一,加 UNIQUE 约束。
四、master_id 与 revision 的语义
PLM 最难理解的两个字段:master_id 和 revision。
4.1 master_id:逻辑实体的 id
master_id 标识"同一个逻辑实体的所有版本"。比如底座 TDJ-25-001,它在 main 上的 revision A / revision B / revision C 都有不同的 id,但 master_id 相同。
plm_items 表:
id=1, master_id=1, item_number='TDJ-25-001', revision='A', lifecycle_state='released'
id=11, master_id=1, item_number='TDJ-25-001', revision='B', lifecycle_state='released' ← 升级后
id=21, master_id=1, item_number='TDJ-25-001', revision='C', lifecycle_state='released' ← 再升级
为什么要 master_id?因为查询时:
- "查 TDJ-25-001 当前生效版本" →
WHERE master_id=1 AND revision='C' - "查 TDJ-25-001 所有历史版本" →
WHERE master_id=1 ORDER BY revision - "查 TDJ-25-001 哪些 ECO 改过它" →
WHERE master_id=1 AND branch_id LIKE 'eco/%'
如果没有 master_id,上述查询要用 item_number 模糊查——但 item_number 在不同 revision 下相同,无 UNIQUE 约束,效率差。
4.2 revision:两种语义
revision 有两种值:
- 发布版:
A/B/C/ ... /Z/AA/AB/ ... /ZZ/AAA - 工作版:
-{8 hex chars branchId},如-0a1b2c3d
工作版只在 eco 分支内有效——它是"还没合并到 main 的草稿版本号"。一旦 eco 分支 merge 回 main,RevisionService 会自动分配一个字母 revision(A→B→C)。
为什么用 -{8 hex} 而不是直接用 draft / in_work 状态标记?因为同一个 eco 分支可能改某 item 多次,每次 saveChanges 都会落一行新的 items(同 masterId),如果不给工作版号,唯一约束 (item_number, revision, design_id, item_type) 就冲突了。所以工作版号必须能区分同一分支内的多次 commit。
-{8 hex branchId} 这种格式很巧妙:
-前缀让它在 ORDER BY revision 里永远排在字母之后(避免被当 main revision 误读)- 8 hex chars = 32 bits,能容纳约 40 亿个分支 id(够用)
- 同分支多次 commit 都用同一个 working_revision_suffix——但因为是 8 chars,每个新 commit 都会创建新 item 行,相同 suffix 不影响唯一性(同分支同 masterId 多次 commit,revision 都是
-0a1b2c3d,但 item_number+revision+design_id+item_type 都相同 → 触发唯一约束冲突)
啊不对——这里我故意没说清楚:实际上 Cascadia 工作版号在每次 commit 后会更新到最新的 commit hash(类似 Git 的 commit sha)。在 yudao 复刻版我简化成"工作版号 = branchId 后 8 hex",让唯一约束冲突由 commit_id 或更细的 suffix 解决。这是一个复刻版的折中——原版更接近 Git,复刻版更易理解。第 03 章我会详细解释这个简化。
五、14 类 Item 完整字段表
这里我把 14 类的全部字段列一遍,对应 ItemTypeRegistry.java 中的注册定义:
| Type | 类型专属表 | 字段(audited=true 会记 item_field_change) |
|---|---|---|
| Part | plm_parts | material* / weight* / manufacturer_part_number / cost* / unit_of_measure |
| Document | plm_documents | category* / file_path* / sha256* / size_bytes |
| ChangeOrder | plm_change_orders | eco_number* / reason* / scope* / scope_locked* |
| Requirement | plm_requirements | requirement_text* / verification_method |
| WorkOrder | plm_work_orders | quantity* / due_date / priority |
| PhysicalPart | plm_physical_parts | serial_number* / manufactured_at* |
| Software | plm_software | version* / build_hash* |
| Tool | plm_tools | tool_type* / calibrated_at* |
| WorkInstruction | plm_work_instructions | step_count / pdf_path* |
| TestPlan | plm_test_plans | test_type* / pass_criteria* |
| TestCase | plm_test_cases | expected_result* |
| SystemElement | plm_system_elements | parent_element_id |
| Issue | plm_issues | severity* / status* |
| Task | (无专属表) | assignee_id* / due_date |
* = audited=true,会进 plm_item_field_changes。
5.1 为什么 Task 没有专属表?
Task 是唯一一个不走 Two-Table Pattern 的类型——只存 items 基表。因为 Task 字段少且简单(assignee_id / due_date),用 items.title + description + JSONB 扩展字段就够。Task 不走 ECO(生命周期是 task-flow 而不是 standard-eco)——它有自己的轻量级状态机。
5.2 ChangeOrder 既是 Item 又是 ECO
注意 plm_change_orders 表:它和 items 表是一对一关系,。"ECO TDJ-25-ECO-001"在 items 表有一行(item_type=change_order),在 change_orders 表也有一行。
为什么这样设计?因为 ECO 本身也是一个可被审计的对象——它的 reason / scope 字段也会被修改、需要走 commit + field_change 流程。把 ECO 当成一个特殊的 Item 类型,可以复用 ItemService 的所有审计逻辑。
但 ECO 又有 ECO 特有的字段(scope_locked / approver_id / released_revisionète),所以再开一张 plm_change_orders 表。
这是 Two-Table Pattern 的精髓——基表统一处理共性,专属表处理差异。
六、Java 枚举:ItemTypeEnum
14 类 Type 在 yudao 复刻版里是 cn.iocoder.yudao.module.plm.enums.ItemTypeEnum:
public enum ItemTypeEnum {
PART("part", "零件", "plm_parts", true),
DOCUMENT("document", "文档", "plm_documents", true),
CHANGE_ORDER("change_order", "工程变更单", "plm_change_orders", true),
REQUIREMENT("requirement", "需求", "plm_requirements", true),
SYSTEM_ELEMENT("system_element", "系统元素", "plm_system_elements", true),
TASK("task", "任务", "tasks", false),
TEST_PLAN("test_plan", "测试计划", "plm_test_plans", true),
TEST_CASE("test_case", "测试用例", "plm_test_cases", true),
WORK_INSTRUCTION("work_instruction", "作业指导书", "plm_work_instructions", true),
ISSUE("issue", "问题", "plm_issues", true),
TOOL("tool", "工装", "plm_tools", true),
SOFTWARE("software", "软件", "plm_software", true),
WORK_ORDER("work_order", "工单", "plm_work_orders", true),
PHYSICAL_PART("physical_part", "实物件", "plm_physical_parts", true);
private final String slug;
private final String displayName;
private final String tableName;
private final boolean hasTypeTable;
}
hasTypeTable 标志位区分走不走 Two-Table Pattern。Task 是 false,其他都是 true。
七、Java 注册中心:ItemTypeRegistry
把 14 类 Type 在启动时注册到一个 EnumMap:
@Component
public class ItemTypeRegistry {
private final Map<ItemTypeEnum, ItemTypeDefinition> registry =
new EnumMap<>(ItemTypeTypeEnum.class);
@PostConstruct
public void init() {
register(ItemTypeEnum.PART, "plm_parts", "standard-eco", List.of(
f("material", "string", true, true, "AL-6061"),
f("weight", "decimal", false, true, null),
...
));
...
}
public Optional<ItemTypeDefinition> lookup(ItemTypeEnum type) { ... }
public boolean hasTypeTable(ItemTypeEnum type) { ... }
}
这是 Code-First 方式——所有类型定义在代码里,启动时注册,编译时检查。对比 Aras 的 UI-Config 方式——管理员在 UI 里点选配置 ItemType,配置存 DB,运行时动态加载。
Code-First 的优点:
- 类型定义走 Git 版本化,谁改了什么字段清清楚楚
- 字段类型 + 是否必填在编译时检查,配错直接编译失败
- 不存在"配置丢失导致生产炸"——配置即代码
缺点:
- 加字段要发版(但其实 PLM 字段变更就该走变更流程)
我后面会专门讲 Aras 的 UI-Config 方式和 Cascadia 的 Code-First 方式的差异(第 04 章适配层)。
八、TDJ-25 demo 的种子数据
第 02 章用的种子数据是简化版 TDJ-25 机械臂:
-- 1. Design:TDJ-25 机械臂
INSERT INTO plm_designs (name, slug, program_id, description)
VALUES ('TDJ-25 6-DOF 机械臂', 'tdj-25-robotic-arm', 1, ...);
-- 2. 5 个零件(masterId 1..5,main HEAD 上都是 revision A)
INSERT INTO plm_items (master_id, item_number, item_type, ...)
SELECT m.mid, 'TDJ-25-00' || m.mid, 'part', ...
FROM (VALUES (1),(2),(3),(4),(5)) AS m(mid);
-- 3. 5 个 part 专属行(material / weight)
INSERT INTO plm_parts (item_id, material, weight, ...)
SELECT i.id, 'AL-6061', 2.5 + i.master_id * 0.5, ...
-- 4. 5 条 BOM 关联(线性 + 1 跳层)
INSERT INTO plm_bom (parent_item_id, child_item_id, quantity, sequence)
SELECT p.id, c.id, 1, 1
FROM plm_items p, plm_items c
WHERE p.master_id = 1 AND c.master_id = 2 AND ...;
真实 Cascadia demo 数据集是 88 parts / 101 BOM / 79 GLB+STEP。我精简到 5 个零件是为了让 SQL 可读——文章里讲到时给你真实数字,SQL 用简化版。
九、为什么不直接抄 Cascadia 的 schema?
有人会问:Cascadia 原版 schema 已经设计好了,直接抄过来不就行了?
不能直接抄,原因:
- Cascadia 用 Drizzle ORM + TypeScript,schema 走
db:push(dev/CI)或db:migrate(生产)。yudao 用 Flyway,schema 走V1__init_xxx.sql文件。 - Cascadia 用 PostgreSQL 18 Row-Level Security 做多租户隔离。yudao 用
tenant_id字段 + MyBatis-Plus 拦截器。 - Cascadia 用 PostgreSQL ARRAY/JSONB/GENERATED columns 重度。yudao 主流项目更保守,JSONB 用但 GENERATED columns 不用。
- Cascadia 原版的
items表字段比 yudao 多(有search_vector/path/tags等),我精简到核心字段,让 yudao 用户看着不晕。
简单说:Cascadia 是给 TypeScript + Drizzle 团队用的;yudao 是给 Java + MyBatis 团队用的,要做适配。这就是第 04 章要讲的"适配层"。
十、本章小结
| 概念 | 一句话总结 |
|---|---|
| 组织层级 | Organization → Program(权限边界) → Design(版本容器) → Items |
| Two-Table Pattern | 基表 `items` 共性 + 类型专属表差异 |
| master_id | 同一逻辑实体的所有版本共享 masterId |
| revision | 发布版 A/B/C… / 工作版 -{8 hex} |
| ItemTypeEnum | 14 类 Type 落地为 Java 枚举 |
| ItemTypeRegistry | Code-First 方式注册类型定义 |
| 唯一约束 | (item_number, revision, design_id, item_type) 兜底并发 revision 分配 |
下一章我们进入核心引擎——ECO-as-Branch:把 Git 工作流引入工程数据。会拆 6 张表(designs / branches / branch_items / commits / item_versions / item_field_changes)+ 5 阶段状态机 + Git 类比 + 分支隔离实现。
