领域模型——6 张表讲清质量追溯

第 2 / 10 章
领域模型——6 张表讲清质量追溯

一、为什么 QMS 的表和 APS 不一样

上一章结尾留了个问题:QMS 的 6 张表,和 APS 的 6 张表差在哪?

APS 的表是"向前看"的——工单、工序、设备、班次日历、模具、排产快照。这条链的语义是"工单拆成工序,工序分配到资源,产出一张计划快照",它是计划视图:描述"将要发生什么"。

QMS 的表是"往回看"的——批次、追溯链路、检验单、检验项、缺陷、SPC 采样点。这条链的语义是"批次之间流转,检验判定合格与否,缺陷决定处置,采样刻画过程能力",它是事实视图:描述"已经发生了什么、以及它们之间是什么关系"。

这个根本差异,直接决定了一张表的存在:APS 不需要"关系表",QMS 必须有。APS 里工单和工序是父子(work_order_id 一列),设备是工序的属性(planned_machine_id 一列)。但 QMS 里批次谱系是"多对多且带类型和方向"的——一批原料投给多个成品(分拆),多批料合成一个成品(装配),塞不进任何一张表的字段,必须单开边表,这就是 qms_trace_link 的来源。

所以这一章表面在讲 6 张表的字段,实质在讲一件事:怎么把"物料谱系"这个天然的图结构,压缩进一个关系库里。

二、ER 极简图(文字版)

先不画密密麻麻的 ER 图,qms-init.sql 头部的注释就是这张图的浓缩版:

qms_batch ──(自引用 parent_batch_id)── qms_batch
   │  │
   │  ├──(N) qms_trace_link ──(from_batch_id / to_batch_id)── qms_batch
   │  │
   │  └──(N) qms_inspection_order ──(N) qms_inspection_item
   │                                  └──(N) qms_defect
   │
   └──(N) qms_spc_sample (按 product_code + characteristic_code 关联,弱关联无外键)

核心关系链有三条:批次 → 批次(qms_batch.parent_batch_id 自引用做单级快查 + qms_trace_link 双向边做完整谱系);批次 → 检验单 → 检验项 → 缺陷(qms_inspection_order.batch_id 挂批次,qms_inspection_item 和 qms_defect 都挂检验单);批次 ↛ SPC(qms_spc_sample 靠 product_code + characteristic_code 弱关联,不与批次建外键)。

所有表都带 tenant_id、creator、create_time、updater、update_time、deleted 这套芋道标准字段——多租户拦截器自动注入 tenant_id,软删除自动处理 deleted。

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

3.1 qms_batch:批次,追溯的主体

CREATE TABLE qms_batch (
    id                    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id             BIGINT       NOT NULL,
    batch_no              VARCHAR(64)  NOT NULL,                  -- 批次号,租户内唯一,如 B-20261013-001
    product_code          VARCHAR(64)  NOT NULL,
    product_name          VARCHAR(128) NOT NULL,
    quantity              INTEGER      NOT NULL DEFAULT 0,
    production_order_code VARCHAR(64),                            -- 关联生产工单编码
    machine_code          VARCHAR(64),
    shift                 VARCHAR(32),                            -- 班次,如 "白班" / "夜班"
    parent_batch_id       BIGINT,                                 -- 谱系父批次(自引用,可空)
    attributes            JSONB        NOT NULL DEFAULT '{}',     -- 批次扩展属性
    status                SMALLINT     NOT NULL DEFAULT 1,        -- 1=在制 2=待检 3=合格 4=不合格 5=已出货 6=冻结
    produce_time          TIMESTAMPTZ,
    expiry_time           TIMESTAMPTZ,
    ...
    UNIQUE (tenant_id, batch_no, deleted)
);
CREATE INDEX idx_qms_batch_parent  ON qms_batch (parent_batch_id);
CREATE INDEX idx_qms_batch_product ON qms_batch (product_code);

对应 QmsBatchDO。两个字段的决策值得展开:

parent_batch_id 既然有了边表为什么还要留? ——因为两种查询性能诉求不同。parent_batch_id 支撑"单级父追溯"(点开批次看直接上一批),最高频,一个 WHERE parent_batch_id = ? 走索引毫秒级;qms_trace_link 支撑"完整多级谱系",重但低频。两者并存是"冗余换性能",代价是可能不一致,收益是最常用的单级查询不碰边表。

// QmsBatchDO.java
/**
 * parentBatchId 是谱系父批次(自引用,可空)——主要用于快速的单级父追溯;
 * 完整的多级正反向谱系由 qms_trace_link 邻接表 + GenealogyGraph BFS 得到。
 * attributes 用 JSONB 存批次扩展属性(供应商/炉号/设备参数等),避免为每个行业建字段。
 */
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> attributes;

attributes 为什么用 JSONB 而不是拆字段? ——因为不同行业扩展属性千差万别:注塑厂关心"供应商、炉号",食品厂关心"产线、操作员",药厂关心"批记录号"。拆成列就得为每个行业加一堆字段,绝大多数是 NULL。用 JSONB 一个字段装天下,JacksonTypeHandler 直接把 JSON 映射成 Map<String, Object>。种子数据里两个批次各带不同语义:

INSERT INTO qms_batch (... attributes ...) VALUES
(0, 'B-20261013-001', ..., '{"supplier":"X塑胶厂","heatNo":"H2026101"}', 3, ...),
(0, 'B-20261013-002', ..., '{"line":"LINE-01","operator":"张三"}', 2, ...);

第一行是原料批(供应商、炉号),第二行是成品批(产线、操作员)——同一张表,两种行业语义。

3.2 qms_trace_link:追溯链路,谱系图的"边"

CREATE TABLE qms_trace_link (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id     BIGINT       NOT NULL,
    from_batch_id BIGINT       NOT NULL,                        -- 上游/输入批次 ID
    to_batch_id   BIGINT       NOT NULL,                        -- 下游/产出批次 ID
    link_type     SMALLINT     NOT NULL DEFAULT 1,              -- 1=投料 2=拼批 3=分拆 4=装配 5=返工
    quantity      DECIMAL(18,4),                                -- 流转数量(可小于批次总量,如部分投料)
    link_time     TIMESTAMPTZ,
    ...
);
CREATE INDEX idx_qms_trace_link_from ON qms_trace_link (from_batch_id);
CREATE INDEX idx_qms_trace_link_to   ON qms_trace_link (to_batch_id);

这张表是整个追溯引擎的地基。QmsTraceLinkDO 的注释点明了它为什么必须独立存在:

// QmsTraceLinkDO.java
/**
 * QMS 追溯链路。批次谱系图的"边":记录某个上游批次以何种方式流向下游批次。
 *
 * link_type 区分投料/拼批/分拆/装配/返工——不同工艺关系的追溯语义不同,
 * 例如"分拆"意味着一批原料产出多个子批,"装配"意味着多个料批合成一个成品批。
 * GenealogyGraph 正向/反向 BFS 均以此为邻接边。
 */
private Long fromBatchId;
private Long toBatchId;
private Integer linkType;

为什么是独立邻接表,而不是在 qms_batch 里放个父指针就完事? ——因为一条父指针只能表达"一个孩子一个爹",表达不了三件事:多对多(装配:3 批料合成 1 个成品,塞不下 3 个爹)、多出(分拆:1 批原料产出 2 个成品,第二个孩子就丢了)、关系类型与方向(投料/拼批/分拆/装配/返工语义不同,返工是"不合格批指向修复批",父指针没有"类型"概念)。

独立邻接表用一行边 (from, to, type, qty, time) 把三件事一次说清,BFS 时正向沿 from → to、反向沿 to → from,方向只影响取哪个端点当 key。两个索引是必须的(idx_qms_trace_link_from / idx_qms_trace_link_to),分别对应正反遍历的起点查找。

3.3 qms_inspection_order:检验单,一次检验活动的主记录

CREATE TABLE qms_inspection_order (
    id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id        BIGINT       NOT NULL,
    code             VARCHAR(64)  NOT NULL,
    inspection_type  SMALLINT     NOT NULL DEFAULT 2,          -- 1=IQC来料 2=IPQC过程 3=FQC成品 4=OQC出货
    batch_id         BIGINT,                                   -- 关联批次 ID(追溯主体)
    work_order_code  VARCHAR(64),
    product_code     VARCHAR(64),
    lot_size         INTEGER      NOT NULL DEFAULT 0,          -- 送检总数(检验批量)
    sample_qty       INTEGER      NOT NULL DEFAULT 0,          -- 抽样数 n
    aql_level        VARCHAR(16),                              -- AQL 水平,如 "2.5"
    accept_qty       INTEGER      NOT NULL DEFAULT 0,          -- 接收数 Ac
    reject_qty       INTEGER      NOT NULL DEFAULT 0,          -- 拒收数 Re
    result           SMALLINT     NOT NULL DEFAULT 0,          -- 0=待检 1=合格 2=不合格 3=让步接收
    inspector        VARCHAR(64),
    inspect_time     TIMESTAMPTZ,
    ...
    UNIQUE (tenant_id, code, deleted)
);

QmsInspectionOrderDO 的注释点出了关键设计:抽样方案"冻结"在检验单上。

// QmsInspectionOrderDO.java
/**
 * QMS 检验单。一次检验活动的主记录,承载抽样方案(n/Ac/Re)与最终判定结果。
 *
 * 与检验项(qms_inspection_item)是一对多:一张检验单下挂多个检验项,
 * submitInspection 汇总所有检验项的不合格数,与 rejectQty(Re)比较得出判定结果。
 */
private Integer lotSize;    // 检验批量
private Integer sampleQty;  // 抽样数 n
private String  aqlLevel;   // AQL 水平
private Integer acceptQty;  // 接收数 Ac
private Integer rejectQty;  // 拒收数 Re

为什么把 n/Ac/Re 存下来,而不是提交判定时现查?因为判定要可复现、可审计。抽样方案一旦定下(如 n=125, Ac=7, Re=8)就钉在检验单上。三个月后客户问"为什么判合格",答案不是"我查了下标准",而是"这张单写着采样 125、接收数 7、实测不合格 1 个,1 < 8,所以合格"。现查的话标准一改就说不清。把瞬时决策固化成历史事实,是质量系统的铁律。

3.4 qms_inspection_item 与 qms_defect

CREATE TABLE qms_inspection_item (
    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    inspection_order_id BIGINT       NOT NULL REFERENCES qms_inspection_order (id) ON DELETE CASCADE,
    item_code           VARCHAR(64)  NOT NULL,                 -- 检验项编码,如 DIM-01
    item_name           VARCHAR(128) NOT NULL,                 -- 检验项名称,如 "外径"
    standard_value      VARCHAR(128),                          -- 标准值(文本描述,如 "10±0.05")
    upper_limit         DECIMAL(18,4),                         -- 规格上限
    lower_limit         DECIMAL(18,4),                         -- 规格下限
    actual_value        DECIMAL(18,4),                         -- 实测值
    item_result         SMALLINT     NOT NULL DEFAULT 1,       -- 1=合格 2=不合格
    ...
);

注意 standard_value(文本,如 10±0.05)和 upper_limit/lower_limit(数值)并存:给人看用文本,给机器判用数值。质检员看到的是行业写法"10±0.05",系统做超差判定、画控制图时需要能比较大小的 BigDecimal。文本里还可能是"无毛刺""目视无划伤"这类无法量化的标准,数值字段留空。item_result = 2 的条数就是本次不合格数,拿去跟 reject_qty 比(详见第 5 章)。

// QmsDefectDO.java
/**
 * QMS 缺陷记录。一次检验中发现的具体缺陷,含严重度、数量、责任工序与处置方式。
 *
 * 处置方式 disposition 为 NULL 表示尚未处置;submitInspection 判定不合格时,
 * 会调用 DispositionRuleEngine 按严重度给出建议处置并回填到未处置的缺陷上。
 */
private Integer severity;            // 1=致命 2=严重 3=轻微
private String  defectCategory;      // "外观" / "尺寸" / "性能"
private Integer disposition;         // 1=返工 2=返修 3=让步接收 4=报废 5=退货;NULL=未处置
private String  responsibleProcess;  // 责任工序
private String  imageUrl;            // 缺陷图片地址

三个字段定调了这张表:severity 决定处置方向、defect_category 参与处置规则匹配、disposition 记录最终去向。disposition 可空很关键——缺陷刚录入时还没有处置决策,它要等检验提交、甚至等 MRB 评审后才回填。"未处置"是一个合法且必要的状态。

3.5 qms_spc_sample:SPC 采样点

// QmsSpcSampleDO.java
/**
 * QMS SPC 采样点。一个控制图子组(subgroup)的样本数据。
 *
 * values_json 存子组内的原始测量值数组(JSONB),mean_value/range_value 由 SpcCalculator 计算回填;
 * cl/ucl/lcl 是控制图的中心线与上下控制限,由 xbarRChart 依据子组容量查 A2/D4/D3 系数得到;
 * rule_violations 存 Western Electric 判异规则的命中描述。
 */
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Double> valuesJson;      // 子组内测量值数组(JSONB)
private BigDecimal meanValue;          // 子组均值 X-bar
private BigDecimal rangeValue;         // 子组极差 R
private BigDecimal clValue;            // 中心线 CL
private BigDecimal uclValue;           // 上控制限 UCL
private BigDecimal lclValue;           // 下控制限 LCL
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> ruleViolations;   // 判异规则命中描述(JSONB)

这张表是 6 张里最"计算密集"的。子组原始测量值和计算结果分开存:values_json 是输入([10.01, 9.99, 10.02, 10.00, 9.98]),均值/极差/控制限是 SpcCalculator 回填的输出。好处是控制限虽随样本累积重算,但历史上每个子组当时用的控制限被冻结在该行里。rule_violations 用字符串数组(如 ["规则1:第 3 点超出 3σ 控制限"]),JSONB 比子表合适。

四、与 APS 的 6 张表对比

两张 6 张表放在一起,差异一目了然:

维度APS 6 张表QMS 6 张表
语义计划视图(将要发生什么)事实视图(已经发生什么 + 关系)
核心关系工单 → 工序 → 资源(树 + 属性)批次 → 链路 → 批次(图)
是否需要边表否(父子用外键列即可)是(`qms_trace_link` 多对多带类型)
时间语义计划时间(planned_start_time)发生时间(produce_time / link_time)
计算密集表`aps_task`(JSONB 候选设备)`qms_spc_sample`(JSONB 测量值)
快照表`aps_schedule_plan`(每次排产的痕迹)无(判定直接落在检验单上)

最后一条差异很有意思:APS 需要 aps_schedule_plan 存每次求解快照,因为排产"一次求解一个结果",要版本化;QMS 不需要,因为每次检验本身就是一个不可变、带时间戳的事实,不必再套一层快照。这也印证那句话:计划需要版本化,事实不需要。

五、踩坑总结

坑一:一开始想用 qms_batch.parent_batch_id 一把梭。 第一版只建了批次表,用父指针连谱系,遇到装配场景(3 批料合成 1 个成品批)直接卡死——一个 parent_batch_id 装不下三个爹。后来才拆出独立的 qms_trace_link。教训:只要关系里出现"多对多"或"带类型/方向",就必须上边表。

坑二:qms_spc_sample 一开始挂了 batch_id 外键。 结果现场录入采样点必须先建批次,而采样常在成品批次建立之前发生。改成 product_code + characteristic_code 弱关联后,SPC 独立于批次,现场录入不再被拦。

坑三:qms_trace_link 忘了建反向索引。 只加 from_batch_id 索引时正向追溯很快,反向(WHERE to_batch_id = ?)走了全表扫描。补上 idx_qms_trace_link_to 后正常。边表这种双向查询的表,两个端点的索引一个都不能少。

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

  • attributes 的 JSONB 解析验证过吗?JacksonTypeHandler 在 autoResultMap = true 时能正确映射 Map<String, Object> 吗?
  • 两个端点索引都建了吗?本地 EXPLAIN 看过正反 BFS 的执行计划吗?
  • parent_batch_id 和 qms_trace_link 的一致性怎么保证?是否在同一事务里同时更新?
  • 检验单的 n/Ac/Re 是否随单冻结?历史判定能否不依赖当前 AQL 主表复现?
  • 孤儿链路怎么处理?qms_trace_link 指向不存在批次的边,GenealogyGraph 遍历时会不会 NPE?

七、下一章预告:AQL 抽样方案引擎

6 张表立住了,接下来的问题是——检验单上的 sample_qty / accept_qty / reject_qty 这三个数,到底从哪来? 谁决定这次送检该抽 125 个还是 80 个?谁决定"超 7 个才判不合格"?这是第 1 章里王主任说的"抽样方案在质检员脑子里"那个问题的正面答案。

下一章讲 AQL 抽样方案引擎——GB/T 2828.1 的两步查表(批量 → 样本量字码 → n/Ac/Re)、为什么用二维数组查表而不是公式现算、SamplingPlanEngine 里三张表的实现、"简化表"的边界在哪里。这是 QMS 系统里第一个纯 Java 引擎。