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

第 2 / 10 章
领域模型——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)
Partplm_partsmaterial* / weight* / manufacturer_part_number / cost* / unit_of_measure
Documentplm_documentscategory* / file_path* / sha256* / size_bytes
ChangeOrderplm_change_orderseco_number* / reason* / scope* / scope_locked*
Requirementplm_requirementsrequirement_text* / verification_method
WorkOrderplm_work_ordersquantity* / due_date / priority
PhysicalPartplm_physical_partsserial_number* / manufactured_at*
Softwareplm_softwareversion* / build_hash*
Toolplm_toolstool_type* / calibrated_at*
WorkInstructionplm_work_instructionsstep_count / pdf_path*
TestPlanplm_test_planstest_type* / pass_criteria*
TestCaseplm_test_casesexpected_result*
SystemElementplm_system_elementsparent_element_id
Issueplm_issuesseverity* / 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 已经设计好了,直接抄过来不就行了?

不能直接抄,原因:

  1. Cascadia 用 Drizzle ORM + TypeScript,schema 走 db:push(dev/CI)或 db:migrate(生产)。yudao 用 Flyway,schema 走 V1__init_xxx.sql 文件。
  2. Cascadia 用 PostgreSQL 18 Row-Level Security 做多租户隔离。yudao 用 tenant_id 字段 + MyBatis-Plus 拦截器。
  3. Cascadia 用 PostgreSQL ARRAY/JSONB/GENERATED columns 重度。yudao 主流项目更保守,JSONB 用但 GENERATED columns 不用。
  4. 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}
ItemTypeEnum14 类 Type 落地为 Java 枚举
ItemTypeRegistryCode-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 类比 + 分支隔离实现。