首页 > 数据库 > MySQL > 条码批次拆分合并怎么建模:父子批次关系表与递归查询
条码批次的拆分与合并,用“批次主表+父子关系边表+批次事务表”三张表建模比较稳妥:批次码一经生成就不再修改,每一次拆分或合并都只新增关系边,再用递归公用表表达式向上追来源、向下追去向。这样无论一个批次被分了几次、又和别的批次合了几次,追溯都只是在一张边表上做图遍历。
“拆合皆边”是指:批次的拆分和合并都不改动原批次记录,而是在关系表中新增“父批次→子批次”的有向边,并用同一个事务号把一次操作产生的所有边串起来。拆分是一父多子,合并是多父一子,两者在数据结构上是同一种东西,所以查询逻辑只需要写一套。
批次建模的基本集合是三张表,外加一张可选的数量流水表用于账实核对。
| 表名 | 关键字段 | 用途 |
|---|---|---|
| lot | lot_no(主键)、material_code、qty_init、qty_remain、status、create_txn | 每个批次一行,记录初始数量、剩余数量与状态(在用/已拆完/已合并/冻结) |
| lot_relation | parent_lot、child_lot、rel_type、qty、txn_id、op_time | 有向边:rel_type 取 SPLIT 或 MERGE,qty 为从父批次转到子批次的数量 |
| lot_txn | txn_id(主键)、txn_type、operator、station、op_time、remark | 一次拆分或合并操作一行,用于审计和整单回滚判断 |
| lot_qty_ledger(可选) | lot_no、txn_id、delta_qty、balance_after | 批次数量流水,便于按时间点重算剩余数量 |
对MES批次谱系,邻接表(父子边表)加递归查询是写入简单、足够灵活的默认选择;只有在谱系很深、反查非常频繁时才考虑闭包表。
| 方案 | 写入成本 | 查询成本 | 合并(多父)支持 | 适合 |
|---|---|---|---|---|
| 邻接表(父子边表) | 每次只写几条边 | 需要递归查询 | 天然支持 | 多数车间的批次拆合 |
| 闭包表 | 每次要写入所有祖先与后代组合,行数随深度增长 | 单次关联即可,查询简单 | 支持,但写入组合更多 | 层级深、查询远多于写入 |
| 路径枚举(存 /A/B/C) | 写入时拼接路径 | LIKE 前缀查询 | 多父时一条路径表达不了,需要冗余多行 | 纯树结构,不推荐用于有合并的批次 |
| 嵌套集合 | 插入要重排左右值 | 子树查询快 | 不支持多父 | 基本不适合批次 |
判断标准很直接:只要存在合并,结构就是有向无环图而不是树,路径枚举和嵌套集合都会变得别扭。
拆分和合并都必须在一个数据库事务里完成:写事务表、写边、扣减父批次剩余数量、创建子批次,任何一步失败整体回滚。
CREATE TABLE lot (
lot_no VARCHAR(40) NOT NULL PRIMARY KEY,
material_code VARCHAR(30) NOT NULL,
qty_init DECIMAL(18,4) NOT NULL,
qty_remain DECIMAL(18,4) NOT NULL,
status VARCHAR(10) NOT NULL,
create_txn VARCHAR(32) NULL
) ENGINE=InnoDB;
CREATE TABLE lot_relation (
parent_lot VARCHAR(40) NOT NULL,
child_lot VARCHAR(40) NOT NULL,
rel_type VARCHAR(5) NOT NULL,
qty DECIMAL(18,4) NOT NULL,
txn_id VARCHAR(32) NOT NULL,
op_time DATETIME NOT NULL,
PRIMARY KEY (parent_lot, child_lot, txn_id),
KEY idx_child (child_lot, parent_lot)
) ENGINE=InnoDB;
-- 拆分示例:批次 L100 拆出 L101、L102
START TRANSACTION;
SELECT qty_remain FROM lot WHERE lot_no = 'L100' FOR UPDATE;
INSERT INTO lot_txn VALUES ('T9001','SPLIT','工号示例','分装工位',NOW(),NULL);
INSERT INTO lot VALUES ('L101','M-示例',30,30,'ACTIVE','T9001'),
('L102','M-示例',20,20,'ACTIVE','T9001');
INSERT INTO lot_relation VALUES ('L100','L101','SPLIT',30,'T9001',NOW()),
('L100','L102','SPLIT',20,'T9001',NOW());
UPDATE lot SET qty_remain = qty_remain - 50 WHERE lot_no = 'L100';
COMMIT;
边表的主键带上 txn_id,是为了允许同一对父子批次在不同操作中多次出现(例如部分退回后再次合并);idx_child 索引专门服务向上追溯。
递归查询要同时带上深度和已访问路径两个字段:深度用来限制层数,路径用来防止数据异常形成环时无限递归。
-- 向上:L205 的全部来源批次
WITH RECURSIVE up AS (
SELECT parent_lot, child_lot, qty, 1 AS depth,
CAST(CONCAT(',', child_lot, ',', parent_lot, ',') AS CHAR(2000)) AS path
FROM lot_relation WHERE child_lot = 'L205'
UNION ALL
SELECT r.parent_lot, r.child_lot, r.qty, u.depth + 1,
CONCAT(u.path, r.parent_lot, ',')
FROM lot_relation r JOIN up u ON r.child_lot = u.parent_lot
WHERE u.depth < 20
AND LOCATE(CONCAT(',', r.parent_lot, ','), u.path) = 0
)
SELECT DISTINCT parent_lot, depth FROM up ORDER BY depth;
-- 向下:L100 去了哪些批次(把 JOIN 方向反过来)
WITH RECURSIVE down AS (
SELECT parent_lot, child_lot, 1 AS depth FROM lot_relation WHERE parent_lot = 'L100'
UNION ALL
SELECT r.parent_lot, r.child_lot, d.depth + 1
FROM lot_relation r JOIN down d ON r.parent_lot = d.child_lot
WHERE d.depth < 20
)
SELECT DISTINCT child_lot, depth FROM down ORDER BY depth;
MySQL 8.0 起支持 WITH RECURSIVE,系统变量 cte_max_recursion_depth 默认为1000层,超过会报错而不是死循环;PostgreSQL 写法基本相同,14版起还可以用 CYCLE 子句检测环;Oracle 既可用递归 WITH,也可用 CONNECT BY 加 NOCYCLE。
数量守恒是批次模型能否被信任的底线:同一事务里,所有父批次被扣减的数量之和必须等于所有子批次获得的数量之和。
SELECT r.txn_id, SUM(r.qty) AS moved_qty, (SELECT SUM(l.qty_init) FROM lot l WHERE l.create_txn = r.txn_id) AS child_init_qty FROM lot_relation r GROUP BY r.txn_id HAVING moved_qty <> child_init_qty;
这条语句适用于“每次操作都新建子批次”的约定;如果合并是并入一个已存在的批次,就要改为核对该批次的数量流水。把它放进夜间巡检,任何返回行都说明有一次操作没写完整或被人工改过数据。
步骤一,和仓库、生产约定批次码规则与“拆分必生成新码、合并必生成新码”的操作规范。
步骤二,建三张表和必要索引,边表同时建父→子与子→父两个方向的索引。
步骤三,把拆分、合并做成服务端事务接口,前端扫码只传父批次码、数量和工位。
步骤四,实现向上、向下两条递归查询,并加深度和环路保护。
步骤五,上线数量守恒巡检,和库存账做一次全量核对。
步骤六,选一个真实物料走完“来料→分装→合并投料→成品”全链路,演练正反向追溯。
1. 拆分时直接改原批次码后缀(L100改成L100-1),历史单据引用全部失效,追溯链断裂。
2. 合并沿用某一个父批次码,导致另一批来源在谱系里“消失”。
3. 拆分没放在一个事务里,网络中断后出现子批次已建、父批次未扣减的半成品数据。
4. 递归查询不做深度和环路保护,脏数据形成环时查询直到撞上递归上限才报错。
5. 边表只建了父批次索引,向上追溯时每一层都要全表扫描。
6. 数量用 FLOAT 存储,多次拆分后出现小数误差,守恒校验总是对不上,应使用 DECIMAL。
7. 允许人工直接改 qty_remain,事后无法从事务表和流水重算出当前数量。
来料检验后,一卷原材料登记为批次 L100。分装工位按工单需要把它拆成 L101 和 L102 两个小包装,分别贴新码。L101 用掉一部分后,余量与另一卷材料拆出来的 L150 在投料口合并成新批次 L205 投入生产。三周后成品抽检发现问题,质量工程师在追溯页输入成品对应的投料批次 L205,向上查询得到 L101、L150,再往上到 L100 和另一卷原材料;随后对 L100 做向下查询,列出同一卷料拆出的 L102 去了哪些工单,决定是否需要扩大隔离范围。
1. 任意一次拆分或合并都能通过 txn_id 查到完整的边、操作人、工位和时间。
2. 数量守恒巡检语句在验收数据上返回空结果。
3. 人为构造一条成环的脏数据,递归查询能正常结束并给出提示。
4. 向上、向下查询结果与人工按单据追踪的结果一致。
5. 原批次码在系统中未被修改,历史单据全部可关联。
6. 在实际数据量下测试多层谱系的查询耗时,并记录测试条件和结果(以现场实测为准)。
问:拆分后剩下的那部分,要不要也生成一个新批次码?
答:两种约定都可行:原批次保留余量、只新建拆出的部分,或者原批次全部拆完。关键是选定一种并写进规范,守恒校验按约定来写。
问:闭包表什么时候值得用?
答:当谱系层级很深、追溯查询频率远高于拆合频率,并且递归查询的耗时实测达不到要求时,可以在边表之外再维护一张闭包表作为查询加速,事实来源仍然只有边表这一张。
问:合并时不同父批次的物料编码不一样怎么办?
答:在合并接口里校验:同物料才允许合并,不同物料的组合属于投料或装配关系,应记录在工序追溯表中,而不是批次关系表。
问:批次冻结后还能拆分吗?
答:建议在拆合接口中检查父批次状态,冻结、已拆完、已报废的批次一律拒绝操作,并把被拒绝的请求记入日志。
问:历史数据量很大时,边表需要分区吗?
答:边表通常比过站记录少得多,一般先靠索引解决;确实需要分区时,注意递归查询会跨越时间,按时间分区的裁剪效果有限。
更新于2026年11月
本文为作者原创,转载请注明出处与作者。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70051529/viewspace-3187327/ ,如需转载,请注明出处,否则将追究法律责任。