领域模型——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 引擎。
