ch06 批次追溯三链:向前/向后追溯 + 召回边界 + CTE 递归

事故现场:36 颗电芯召回查了 11 天,5 个跨工序会议、12 张 Excel
事故 3 的复盘要把当时的查追溯过程还原:2025 年 12 月 17 日 09:23,客户在装配 Pack 后做 EOL 测试,发现某颗 280Ah 半固态电芯(SN: X280A2025121500231,下文记为 SN-231)充电 14 天后内短路。客户要求工厂按"同一物料批次"召回——客户合同里"同一物料批次"的定义是:同一正极粉料批次 + 同一涂布卷 + 同一化成柜 + 同一化成曲线的所有电芯。
工厂接到召回通知后,质量工程师老李打开 Excel 开始查:
第 1 天(12/17):老李从客户那里拿到 SN-231,从 SAP 工单查到这颗电芯出自工单 WO-20251215-018,1000 颗,型号 X-280,化成曲线 FORN-280-CHG-V3。SAP 能查到工单但查不到物料批次——物料批次信息在哪?

第 2-3 天(12/18-19):老李跑到涂布车间,找涂布班组长查"12 月 15 日给工单 WO-...-018 供料的正极粉料批次号是什么"。涂布班组长翻交接班记录本,找到 12 月 15 日 A 班用了粉料批次 LFP-PW-20251128-007(128 公斤),但记录本没写这批粉料做了几卷极片、流到哪条卷绕线。涂布班组长让老李去问辊压车间。
第 4-5 天(12/20-21):辊压车间说"我们按卷接收按卷产出,没记粉料批次号"。老李又问涂布车间能不能从涂布机日志反查。涂布机日志在 SCADA 里,SCADA 工程师导了 4G 数据,老李用 Python 脚本按时间过滤——找到 12 月 15 日 08:23-09:45 用了粉料批次 007 涂了 1 卷(卷号 BC-001-20251215-003)。但辊压没记粉料批次这信息断了,老李只能假设"辊压按卷顺序接收,涂布卷号 BC-...-003 应该进了辊压机 RP-001 的第 N 卷"。
第 6-7 天(12/22-23):老李追到卷绕车间,卷绕机 WND-003 的 SCADA 日志显示 12 月 15 日 14:30 卷了 240 颗裸芯,但卷绕机也没记极片卷号——只知道按时间窗口产出了 240 颗裸芯,裸芯 SN 区间 X280A2025121500001-240。老李用 Excel 把这 240 颗裸芯标记为"可能受影响",但卷绕机日志没和辊压日志对齐,"哪些裸芯用了 BC-...-003 这卷"还是猜的。
第 8-10 天(12/24-26):化成车间比较好查——化成柜 F1-012 的 SCADA 日志显示 12 月 15 日 22:00 进了 240 颗裸芯,化成曲线 FORN-280-CHG-V3。这 240 颗裸芯就是召回范围。但其中 36 颗已经发往客户了,老李查 ERP 销售订单,36 颗发往 3 个客户,只有 1 个是投诉客户。最终召回范围:36 颗 + 现仓的 204 颗返检。
第 11 天(12/27):老李把 12 张 Excel 整合成报告,圈定 36 颗召回 + 204 颗返检,开召回会。厂长问:"这 11 天里你做了什么?",老李说:"60% 时间在跨车间求人查数据,30% 时间在 Excel 里 VLOOKUP,10% 时间在写 Python 脚本过滤 SCADA 日志"。厂长:"那如果客户合同定义'同一电解液批次'召回呢?",老李:"……我可能要再花 11 天"。
排查:为什么追溯链会断
复盘把 11 天的追溯过程拆开,发现追溯链在 4 个工序节点断了:
| 工序 | 断点 | 原因 |
|---|---|---|
| 涂布→辊压 | 极片卷号没传递 | 辊压车间按"辊数"接收,没扫涂布卷号 |
| 辊压→分切 | 辊压卷号没传递 | 分切按"米数"接收,没扫辊压卷号 |
| 分切→卷绕 | 分切后小卷号没传递 | 卷绕按"裸芯数量"接收,没扫分切卷号 |
| 卷绕→注液 | 裸芯 SN 已生成但没扫到注液 | 注液按"批量"接收,只记了批次号没记 SN |
每一段断点都是同样的根因:下游工序接收上游物料时,没强制扫描上游的批次号/SN,只记自己工序的产出批次号。这导致追溯链只能正向走(从粉料推到电芯),反向走(从电芯追回粉料)必须靠时间窗口猜——客户召回正是反向追溯,正好踩在断点上。
更深层根因:没有"批次三链"模型——业界把物料追溯抽象为三条链:料链(物料批次)、工序链(工序产出)、设备链(设备时段),三条链必须交叉关联,任何一颗电芯都能从料链反查工序链再反查设备链,反之亦然。电芯厂 A 的现状是每条链都记了,但三条链之间没关联——料链在 ERP 里、工序链在 MES 派工单里、设备链在 SCADA 日志里,三套系统三套 ID。
底层原理:批次三链与 CTE 递归追溯
批次三链的数据模型
批次三链的核心是一个物料移动事件同时记录三条链的 ID:
[料链] 料批 LFP-PW-20251128-007 → 涂布极片卷 BC-001-...-003
[工序链] 工单 WO-...-018-OP1 → 涂布产出 BC-001-...-003
[设备链] BC-001 @ 12/15 08:23-09:45 → 产出 BC-001-...-003
每条物料移动记录("X 批次 → Y 工序 → Z 设备 → W 产出批次")都同时记录三条链的 ID,这样反查时任意一条链都能 join 到另两条。物料移动表设计:
CREATE TABLE material_movement (
id BIGINT PRIMARY KEY,
event_time TIMESTAMP NOT NULL,
-- 料链
from_lot_id BIGID, -- 上游批次号(料批/卷号/SN)
from_lot_type VARCHAR(16), -- POWDER/COIL/CELL/...
-- 工序链
work_order_id BIGINT NOT NULL,
operation_id BIGINT NOT NULL, -- 1=涂布 2=辊压 ... 7=分容
-- 设备链
device_id BIGINT NOT NULL,
-- 产出
to_lot_id BIGINT, -- 本工序产出的新批次号
to_lot_type VARCHAR(16),
quantity DECIMAL(12,4),
unit VARCHAR(16),
-- 关键:前后移动的关联(递归追溯的入口)
prev_movement_id BIGINT, -- 上游的 movement_id
next_movement_id BIGINT -- 下游的 movement_id(首次入库时为空)
);
CREATE INDEX idx_mm_from_lot ON material_movement(from_lot_id);
CREATE INDEX idx_mm_to_lot ON material_movement(to_lot_id);
CREATE INDEX idx_mm_wo_op ON material_movement(work_order_id, operation_id);
电芯 7 段工艺的物料移动链条:
涂布: 粉料 LFP-PW-...-007 → BC-001 @ 08:23 → 极片卷 BC-...-003
辊压: 极片卷 BC-...-003 → RP-001 @ 10:15 → 辊压卷 RP-...-003
分切: 辊压卷 RP-...-003 → SL-001 @ 11:30 → 分切小卷 SL-...-001~024
卷绕: 分切小卷 SL-...-001~024 → WND-003 @ 14:30 → 裸芯 SN X280A...0001-0240
注液: 裸芯 SN + 电解液 EC-...-005 → FL-001 @ 16:00 → 注液裸芯 SN(同 SN)
化成: 注液裸芯 SN → F1-012 @ 22:00 → 化成电芯 SN(同 SN)
分容: 化成电芯 SN → FC-001 @ 次日 → 成品 SN(同 SN)
每行 material_movement 都有 prev_movement_id 指向上一段的 movement,next_movement_id 指向下一段——整条链是一个双向链表。
CTE 递归 SQL:30 分钟追溯到 30 秒
老李用 11 天做的事,本质是从成品 SN 反向走完 6 段 material_movement 链——SQL 用 CTE 递归 30 秒搞定:
-- 反向追溯:从成品 SN 反查到粉料批次
WITH RECURSIVE backward_trace AS (
-- 起点:从 SN 找到它在分容工序的 movement
SELECT m.id, m.from_lot_id, m.to_lot_id, m.operation_id, m.device_id,
m.prev_movement_id, 1 AS depth
FROM material_movement m
JOIN lot l ON l.id = m.to_lot_id
WHERE l.lot_no = 'X280A2025121500231' -- SN-231
AND m.operation_id = 7 -- 分容工序
UNION ALL
-- 递归:沿 prev_movement_id 反向走
SELECT m.id, m.from_lot_id, m.to_lot_id, m.operation_id, m.device_id,
m.prev_movement_id, t.depth + 1
FROM material_movement m
JOIN backward_trace t ON m.id = t.prev_movement_id
WHERE t.depth < 20 -- 防止无限递归
)
SELECT * FROM backward_trace ORDER BY depth;
这条 SQL 返回 SN-231 的完整反向追溯链:分容→化成→注液→卷绕→分切→辊压→涂布,每段的 from_lot_id / to_lot_id / device_id 都查出来。
向前追溯(从粉料批次找到所有受影响的成品 SN)反过来:
WITH RECURSIVE forward_trace AS (
-- 起点:从粉料批次找到它在涂布工序的 movement
SELECT m.id, m.from_lot_id, m.to_lot_id, m.operation_id, m.device_id,
m.next_movement_id, 1 AS depth
FROM material_movement m
JOIN lot l ON l.id = m.from_lot_id
WHERE l.lot_no = 'LFP-PW-20251128-007' -- 粉料批次
AND m.operation_id = 1 -- 涂布工序
UNION ALL
-- 递归:沿 next_movement_id 正向走
SELECT m.id, m.from_lot_id, m.to_lot_id, m.operation_id, m.device_id,
m.next_movement_id, t.depth + 1
FROM material_movement m
JOIN forward_trace t ON m.id = t.next_movement_id
WHERE t.depth < 20
)
SELECT * FROM forward_trace ORDER BY depth;
向前追溯返回粉料批次 007 影响的所有下游批次——极片卷、辊压卷、分切小卷、裸芯 SN、注液裸芯、化成电芯、成品 SN。最后一行 depth=7 的 to_lot_id 就是受影响的成品 SN 列表,召回范围一次 SQL 圈定。
CTE 递归性能:14 万条 movement 30 秒
老李担心递归 SQL 性能。我们用 14 万条 material_movement(电芯厂 A 半年数据)做压测:
| 追溯方向 | 数据量 | 耗时 | 索引使用 |
|---|---|---|---|
| 反向追溯(SN→粉料) | 14 万 movement | 0.32 秒 | idx_mm_from_lot + prev_movement_id |
| 正向追溯(粉料→所有 SN) | 14 万 movement | 28.7 秒 | idx_mm_to_lot + next_movement_id |
| 多 SN 批量追溯 | 36 个 SN 反向 | 1.4 秒 | 同上 |
正向追溯慢是因为一棵粉料批次可能影响几百个成品 SN,递归深度 7 层、宽度 N 倍增长。优化手段:
- next_movement_id 必须建索引——单条 B-tree 索引能跳过全表扫描。
- 递归深度限制 depth < 20——电芯最多 7 段,20 层足够,防止数据有环时无限递归。
- 大批量追溯用物化视图——若常查"粉料批次 X 影响哪些 SN",建物化视图 mv_lot_forward_cache 增量更新。
- 召回场景用 forward_trace 反向——召回是从成品 SN 反查粉料,走 backward_trace 路径(30 秒内)。
正确姿势:批次三链的工程实现
1. 物料接收强制扫批次号
事故根因是下游工序接收时不扫上游批次号。工程上的解法是物料接收必须扫 from_lot_id,不扫不让接收:
@Service
public class MaterialReceiveService {
@Autowired private MaterialMovementMapper movementMapper;
@Autowired private LotService lotService;
@Transactional
public MovementResult receive(ReceiveCommand cmd) {
// 1. 校验 from_lot_id 必须扫
if (cmd.getFromLotId() == null) {
throw new BizException("物料接收必须扫上游批次号");
}
// 2. 校验 from_lot 存在且未消耗完
Lot fromLot = lotService.getById(cmd.getFromLotId());
if (fromLot == null) {
throw new BizException("上游批次 " + cmd.getFromLotId() + " 不存在");
}
BigDecimal consumed = movementMapper.sumConsumed(cmd.getFromLotId());
if (consumed.add(cmd.getQuantity()).compareTo(fromLot.getQuantity()) > 0) {
throw new BizException("上游批次余量不足: 余 " +
fromLot.getQuantity().subtract(consumed) +
" 需 " + cmd.getQuantity());
}
// 3. 生成本工序产出的新批次(to_lot)
Lot toLot = lotService.createChildLot(fromLot, cmd);
// 4. 写 material_movement,链上 prev_movement_id 指向上游最近一条
MaterialMovement prev = movementMapper.findLatestByToLot(cmd.getFromLotId());
MaterialMovement m = new MaterialMovement();
m.setFromLotId(cmd.getFromLotId());
m.setToLotId(toLot.getId());
m.setWorkOrderId(cmd.getWorkOrderId());
m.setOperationId(cmd.getOperationId());
m.setDeviceId(cmd.getDeviceId());
m.setQuantity(cmd.getQuantity());
m.setPrevMovementId(prev != null ? prev.getId() : null);
movementMapper.insert(m);
// 5. 反向更新上游 movement 的 next_movement_id
if (prev != null) {
movementMapper.updateNextMovementId(prev.getId(), m.getId());
}
return new MovementResult(m.getId(), toLot.getId());
}
}
2. SN 在注液后冻结,全程不变
电芯的特殊性:注液工序开始才有 SN,注液之前都是按卷/批追溯,注液之后按 SN 追溯。所以 SN 必须在注液工序生成,生成后冻结全程不变(化成、分容都用同一个 SN):
@Service
public class SnService {
@Autowired private LotService lotService;
// 注液工序生成 SN
@Transactional
public List<String> generateSnForFilling(String woNo, int count) {
WorkOrder wo = woService.getByNo(woNo);
String snPrefix = buildSnPrefix(wo); // X280A20251215
List<String> sns = new ArrayList<>();
for (int i = 1; i <= count; i++) {
String sn = snPrefix + String.format("%04d", i); // X280A202512150001
lotService.createSnLot(sn, wo.getProductItemId());
sns.add(sn);
}
return sns;
}
// 化成/分容工序接收时校验 SN 已存在
public void validateSn(String sn) {
if (lotService.getByLotNo(sn) == null) {
throw new BizException("SN " + sn + " 未在注液工序生成");
}
}
}
3. 召回边界规则引擎
客户合同里"同一物料批次"召回的定义五花八门,必须用规则引擎配置:
public interface RecallRule {
List<Long> findAffectedLots(String seedLotNo);
}
// 规则 1:同一粉料批次召回
@Component("samePowderLot")
public class SamePowderLotRecallRule implements RecallRule {
@Autowired private TraceDao traceDao;
public List<Long> findAffectedLots(String powderLotNo) {
// CTE 正向追溯 depth=1 到 depth=7
return traceDao.forwardTrace(powderLotNo, 7);
}
}
// 规则 2:同一化成曲线召回(柜+曲线)
@Component("sameFormationCurve")
public class SameFormationCurveRecallRule implements RecallRule {
public List<Long> findAffectedLots(String curveCode) {
// 找到所有用该曲线的化成 movement,再正向走到成品 SN
return traceDao.findByFormationCurve(curveCode);
}
}
// 规则 3:同电解液批次召回
@Component("sameElectrolyteLot")
public class SameElectrolyteLotRecallRule implements RecallRule {
public List<Long> findAffectedLots(String electrolyteLotNo) {
// 找到所有用了该电解液的注液 movement,正向走到成品
return traceDao.forwardTraceFromOp(electrolyteLotNo, 4); // 注液=op4
}
}
@Service
public class RecallService {
@Autowired private Map<String, RecallRule> rules;
public RecallResult recall(String ruleName, String seedLotNo) {
RecallRule rule = rules.get(ruleName);
if (rule == null) {
throw new BizException("召回规则 " + ruleName + " 不存在");
}
List<Long> affectedLotIds = rule.findAffectedLots(seedLotNo);
// 区分已发客户 / 在库
List<Lot> shipped = lotService.findShipped(affectedLotIds);
List<Lot> inStock = lotService.findInStock(affectedLotIds);
return new RecallResult(affectedLotIds, shipped, inStock);
}
}
数据说话:11 天 → 30 秒的对比
上线批次三链后,跑了一次召回演练——模拟客户投诉 SN-231,按"同一粉料批次"召回:
| 维度 | 老李手工(事故 3) | 批次三链系统 | 改进 |
|---|---|---|---|
| 追溯耗时 | 11 天 | 32 秒 | **3 万倍** |
| 涉及人 | 5 个跨工序会议 | 1 个质量工程师 | -80% 人力 |
| Excel 表数 | 12 张 | 0 张 | -100% |
| 召回范围准确率 | 36 颗(猜的) | 36 颗(精确) | 100% 准确 |
| 误召回风险 | 高(猜时间窗口) | 0 | 0 误召回 |
| 召回报告生成 | 11 天手写 | 自动生成 | -100% 工时 |
| 不同规则召回 | 重新 11 天 | 切规则 30 秒 | 灵活性指数级 |
关键发现:
- 32 秒圈定召回范围——CTE 递归 SQL 0.32 秒,剩余 31.7 秒是 join 物料批次表查"已发客户/在库"状态、生成召回报告 PDF。
- 召回准确率 100%——老李手工时是按时间窗口猜的,可能漏召回也可能多召回。批次三链是按 material_movement 链精确圈定,不多不少。
- 切换召回规则 30 秒——客户合同改定义"同电解液批次"召回时,老李要再花 11 天,系统切 RecallRule 只需 30 秒。
面试怎么答:批次追溯与召回
问:电芯厂的批次追溯与机械装配的 SN 追溯有什么本质不同?
答:三点本质差异:① 追溯单位——机械装配全程按 SN 追溯(每颗螺丝都有 SN),电芯在注液前按"批次/卷号"追溯(粉料按批次、极片按卷号),注液后才按 SN 追溯,追溯单位中途切换;② BOM 形态——机械装配 BOM 是离散(1 件壳 + 4 颗螺丝 = 1 件成品),电芯 BOM 是连续-离散混合(200g 粉料 → 1 卷极片 → 切 24 段 → 卷 240 颗裸芯),1 对 N 展开,追溯链宽度爆炸式增长;③ 召回规则——机械召回按"同批次零件"召回,规则单一,电芯召回按客户合同定义(同粉料/同电解液/同化成曲线/同设备),多规则可配置,需要规则引擎。
问:CTE 递归 SQL 追溯有什么性能瓶颈?怎么解决?
答:CTE 递归性能瓶颈在三个地方:① 递归深度——电芯 7 段工艺递归深度 7,看起来不大,但每段是 1 对 N 展开(1 卷极片 → 24 段 → 240 颗裸芯),递归宽度指数增长,14 万条 movement 数据下正向追溯 28 秒;② 索引使用——递归 SQL 必须用 prev_movement_id / next_movement_id 索引,否则全表扫描直接挂;③ 环检测——若数据有环(movement A 的 next 指向 B,B 的 next 又指回 A),递归会无限循环,必须 depth < 20 兜底。解决方案:① next_movement_id 单独建索引;② depth 限制 20 层;③ 大批量追溯用物化视图 mv_lot_forward_cache 增量更新;④ 召回场景优先用反向追溯(30 秒以内)。
问:物料接收强制扫批次号为什么能解决追溯链断的问题?
答:事故根因是下游工序接收时不扫上游批次号——辊压按"辊数"接收、卷绕按"裸芯数量"接收,都只记自己工序的产出批次,没把上游批次号关联进来。强制扫批次号从工程上做了三件事:① 数据完整性——from_lot_id 字段非空,不允许 NULL;② 余量校验——扫的 from_lot 余量必须够,不够不让接收,防止超领;③ 双向链表——扫的瞬间同时写 prev_movement_id 指向上游、反向更新上游的 next_movement_id 指向自己,链表双向打通。这三件事让追溯链物理上不可能断,事故 3 的 11 天追溯直接降到 30 秒。
问:召回规则怎么做成可配置?不能写死在代码里?
答:召回规则用 RecallRule 接口 + 多实现类 + Spring 注入 Map<String, RecallRule> 实现。每个规则(同粉料批次、同电解液批次、同化成曲线、同设备)一个实现类,规则名作为 key。运营在召回界面选规则名,系统从 Map 里取对应实现类调 findAffectedLots()。规则配置不在代码里写死——规则元数据(规则名、参数定义、SQL 模板)放在 recall_rule_config 表里,运营可加新规则不重启服务(用 Spring 动态 Bean 注册或策略模式 + 反射)。客户合同里写"同 X 召回"时,运营在配置表加一条规则,业务无代码改动。
落地清单:批次追溯工程化动作
- 物料移动表 material_movement:from_lot_id + to_lot_id + work_order_id + operation_id + device_id + prev_movement_id + next_movement_id,六元组必填,缺一不可追溯。
- 三个索引必建:idx_mm_from_lot(反向追溯)、idx_mm_to_lot(正向追溯)、idx_mm_wo_op(按工单查所有 movement)。
- 物料接收强制扫批次号:from_lot_id 非空,校验余量,写 prev/next 双向链表,禁止"按数量接收不扫批次"。
- SN 在注液工序生成后冻结:注液前按批次/卷号追溯,注液后按 SN 追溯,SN 全程不变(化成/分容都用同 SN)。
- CTE 递归 SQL 双向追溯:backward_trace(SN→粉料)+ forward_trace(粉料→SN),depth < 20 兜底防环。
- 召回规则引擎:RecallRule 接口 + 多实现 + Map 注入 + recall_rule_config 配置表,业务无代码改动加规则。
- 召回报告自动生成:圈定受影响 lot 后,自动 join 库存表区分已发客户/在库,生成 PDF 报告。
- 黄金回归:① 注液工序生成的 SN 在化成/分容 movement 中作为 from_lot_id 必须出现;② 辊压接收不扫极片卷号必须被 from_lot_id 非空校验拦下;③ CTE 递归 depth=20 时返回的行数不超过预期;④ 召回规则 samePowderLot 圈定的 SN 数与 forward_trace SQL 结果一致。
下一章我们离开追溯链进入绩效分析层,看 OEE 三因子怎么把"88% OEE 实际是 77%"的口径问题拆穿——这是事故 2 的核心防线。
