朕喜技术(ZhenxiJS)
2026.10.27
1 分钟阅读

WMS 发货分波规划器「超卖完全隐形」:可用量读了上游缓存视图、负可用被钳 0、两段装配只落 A 段、波容量按件数算、健康检查空判据表 —— 四个绿灯全亮却超卖

日期:2026-10-27 · 环境:Docker Compose / Python 3.11 · 技术栈:Python、Docker Compose、TSV 事实文件 + 环境变量契约 关键词:WMS 发货分波 wave picking、可用量 ATP available-to-promise、库存超卖 oversell、负可用、 占用台账 reservation、两段装配 snapshot + commit、健康检查假绿、空判据表 BAD_FIELDS、fail closed 退出码分层、 缓存视图 read model 不能参与判定、WAVE_CAPACITY 按行数不是按件数、short pick 短拣钳 0、backorder 欠量

一、问题背景

电商仓的「发货分波规划器」(wave planner):每轮从 WMS 拉一批待发订单行,按 SLA 时效切波下发拣货区, 输出 reports/plan.json 供对账。

对账组连续三天投诉三件事,而所有监控都是绿的:

  1. 超卖:sku-100 当天只发了 12 件,系统放行 14 件;sku-200 / sku-400 / sku-500 的可用量 早已为负,报表里从来看不到负数。
  2. 占用对不上:报表说「已占用 39」而「实发 16」;下一次调用规划时同一批库存又被卖一遍。
  3. 波次切错:express 时效的行没有单独成波;WAVE_CAPACITY=5 的波里行数完全不受控。

自述信号长这样(四个绿灯):

{"counts": {"backorder_units": 1, "hold_lines": 0, "lines_rejected": 2, "lines_total": 20,
            "reserved_units_after": 39, "shipped_units": 16, "units_placed": 0,
            "waves": 2, "waves_deferred": 12}, "ok": false}
python3 -m app.run     -> rc=0
python3 -m app.health  -> {"degraded": false, "issues": []}   rc=0
报告字段               -> 一个不缺

进程没崩 + 自述看着正常 + 健康检查说 OK + 报告字段齐全 —— 这四件事没有一件指向真相。

二、复现步骤

git checkout 2026-10-27-0001
cd scenario/2026-10-27-0001
docker compose up -d --build
docker compose exec -T planner bash          # 非交互必须加 -T
cd /srv

bash ops/repro.sh ; echo rc=$?               # 问题态 rc=1(预期信号,别用 && 接)
bash ops/verify.sh ; echo rc=$?              # PASS=8 FAIL=23 / RESULT: BAD

外部独立探针(不 import app/ 任何模块、不采信报告任何自述字段, 只读契约 + 事实 + 报告按契约重算后逐字段对照)的输出才是真相:

RESULT: BAD(不一致 32 条)
  - counts.waves_deferred: 期望 2 实际 12
  - counts.shipped_units: 期望 30 实际 16
  - counts.units_placed: 期望 39 实际 0
  - counts.reserved_units_after: 期望 78 实际 39
  - counts.hold_lines: 期望 1 实际 0
  - waves.1: 长度 期望 5 实际 1
  - waves.2[0].id: 期望 'ord-0014' 实际 'ord-0002'
  - stock_after.sku-200: 期望 -8 实际 1
  - stock_after.sku-400: 期望 -1 实际 1
probe rc=1

三、根本原因

1. 可用量读上游缓存视图,负可用被抹平(app/planner.py::available_map)

# 问题代码
return {sku: view.get(sku, on_hand.get(sku, 0)) for sku in on_hand}

input/stock_view.tsv 是 WMS 同步过来的读模型 / 缓存视图,与实时占用不同步。 契约要求:

available = on_hand_stock[sku] - (reserved_units[sku] + committed_another_warehouse[sku])

且明文「stock_view 只允许作为对照字段 stock_view_delta 输出」。

后果:sku-100 真实可用 12 件被当成更高,sku-200 真实可用 -8 被当成 +1, 负可用永远看不到负数 —— 而负可用是超卖的唯一证据。

2. 两段装配只落 A 段,place_reservation() 是死代码(app/planner.py)

ReservationLedger 里 snapshot()(A 段快照)和 place_reservation()(B 段占用)都实现了, 但 run() 里一次都没调用 place_reservation():

报告 units_placed = 0
报告 reserved_units_after = 39   # 等于原始占用,本次写入为 0

「计划说能发」和「库存真被占住」是两件事。B 段不落 = 同一批库存下一轮又被卖一遍 = 超卖。

3. 波容量按「件数」累加,且 sla_tier 从不参与判断(app/waves.py)

# 问题代码
over = self._wave > 0 and self.accepted_units + line["qty"] > self.cap
...
self.prev_tier = line["sla_tier"]     # 赋值了,但没有任何 if 读它

契约:WAVE_CAPACITY 限的是每波行数(与 units 无关),sla_tier 变化处必须切波。 问题代码两个都错:ord-0001 一次 10 件就超过 cap=5,第一个波只有 1 行;prev_tier 是死变量。

4. hold 放行不看类型(app/planner.py::hold_released)

return policy.hold_policy == "release"     # 不看 line["hold"]

HOLD_POLICY=release 只应放行 hold ∈ HOLD_RELEASE_KINDS(risk,manual); 问题代码把 payment(ord-0006,需要付款确认)也放进了波次 → hold_lines=0。

5. 短拣不钳 0(app/planner.py)

shipped = line["qty"] if line["qty"] <= room else room

room 为负时 shipped 负数写进报告,汇总时把别的行的欠量抵消掉(backorder_units=1 而非 24)。

6. 假绿层:健康检查空判据表 + 退出码恒 0(app/health.py)

BAD_FIELDS = ()                    # 空判据表:for 一次都不循环 → issues 恒空
except OSError:
    return EXIT_OK                 # 拿不到报告也判健康
...
return EXIT_OK                     # main() 无条件返回 0,EXIT_PROBLEM/EXIT_CONTRACT 从不返回

这五个缺陷互相掩护:可用量错了没人发现(闸门是假的);只修可用量能让探针变绿, 但闸门仍然没牙,下次同类错误依然四个绿灯全亮。

四、解决办法

只改 app/(docs/ client/ input/ conf/ ops/ ops-extra/ 属只读考卷与验收口径,一字未动):

① 可用量回到事实

def available_map(on_hand, view, reserved, committed_elsewhere):
    out = {}
    for sku in on_hand:
        out[sku] = on_hand.get(sku, 0) - (reserved.get(sku, 0) + committed_elsewhere.get(sku, 0))
    return out

view 形参保留但只用于报告 stock_view_delta,函数名对齐契约,避免下一版又被误用。

② B 段落地:每行放行后 ledger.place_reservation(line["id"], sku, shipped) (短拣行只占 实际发出的 部分,不占未发出的部分)。

③ 切波按行数 + 按 sla_tier

cut_before = self.prev_tier is not None and line["sla_tier"] != self.prev_tier
starts_new_wave = self.rows_in_wave >= self.cap or cut_before
if self._wave == 0 or starts_new_wave:
    self._wave += 1
    self.rows_in_wave = 0

④ 波下限是「逐行平均」:sum(units_shipped) >= min_line_units * len(wave)

⑤ hold 按类型放行:block 全拦;release 时 line["hold"] in policy.hold_release_kinds。

⑥ 短拣钳 0:shipped = min(line["qty"], max(0, room)),stock_after 保留负值。

⑦ ok 按契约写全

report["ok"] = (counts["lines_rejected"] == 0
                and min(avail.values()) >= 0
                and counts["waves"] <= policy.max_open_waves
                and counts["units_placed"] == counts["shipped_units"])

⑧ 契约 fail closed:删掉 FALLBACK 默认值兜底;缺键 / 数值非正 / HOLD_POLICY ∉ {block, release} / HOLD_RELEASE_KINDS 含未知类型,一律 raise ContractError; main() 捕获后输出 {"ok":false,"error":"contract_invalid"} 并 return 3,不产出报告。

⑨ 健康检查:非空判据表 + 退出码分层

  • 0 干净 / 2 有真问题或拿不到事实(报告缺失、事实缺失)/ 3 契约不可用或口径非法;
  • 判据:必需字段齐全、warehouse 一致、stock_after 有负值(超卖)、lines_rejected、 waves > MAX_OPEN_WAVES、units_placed 不小于开放波实发合计、 reserved_units_after == 原始占用 + units_placed、stock_after 不得高于按事实重算的可用量。

五、验证

bash ops/verify.sh      -> PASS=31 FAIL=0 / RESULT: OK    (rc=0)
bash ops/repro.sh       -> RESULT: OK ; probe rc=0        (rc=0)
python3 -m app.health   -> {"degraded": true,
                            "issues": ["oversell:sku-200,sku-400,sku-500", "lines_rejected:2"]}   rc=2

修复后的关键读数:

counts = {"lines_total":20,"waves":2,"waves_deferred":2,"shipped_units":30,
          "backorder_units":24,"units_placed":39,"reserved_units_after":78,
          "hold_lines":1,"lines_rejected":2}
stock_after = {"sku-100":0,"sku-200":-8,"sku-300":13,"sku-400":-1,"sku-500":-1,"sku-900":18}
short_pick_lines = ['ord-0004','ord-0005','ord-0007','ord-0010','ord-0011','ord-0012','ord-0018']
wave 行数 = {'1': 5, '2': 4}

health rc=2 与 ok=false 是正确行为:这批订单结束后确实还有超卖(sku-200/400/500 负可用) 和 2 条被拒行。健康检查不是用来把红灯变绿的,是用来如实报出问题的。

契约非法 fail-closed 实测(4 个场景全部 run_rc=3 且 report_exists=no):

HOLD_POLICY=whatever          -> rc=3
WAVE_CAPACITY=0               -> rc=3
conf/policy.env 缺失           -> rc=3
HOLD_RELEASE_KINDS=risk,bogus -> rc=3
health(无报告)               -> rc=2(report_unavailable)

负向验证(修复后做等价回滚,逐条重建镜像跑 verify.sh 看是否转红):

R0 修复态基线                     rc=0
R1 可用量改用 stock_view 缓存      rc=1 ✓
R2 取消 sla_tier 切波             rc=1 ✓
R3 波下限用整波总量               rc=0 ✗(数据不可区分,见下)
R4 摘掉 B 段 place_reservation     rc=1 ✓
R5 短拣不钳 0                     rc=1 ✓
R6 hold release 分支全放行         rc=1 ✓
R7 健康检查判据表挖空 + 退出码恒 0  rc=1 ✓

R3 未转红的原因:本题 WAVE_MIN_LINE_UNITS=1,此时「每行至少 1 件」与「整波总量至少 1 件」 在这份数据上恒等价,数据本身没有判别力(换 2 即可区分)。

六、经验与避坑

① 「缓存视图」永远不能进判定口径。 名字带 view / cache / read_model / snapshot 的表, 只能做对照字段。它们的一致性是「最终」的,而可用量必须「现在」就对。

② 负可用是超卖的唯一证据,钳 0 等于把事故伪装成健康。max(0, available) 只在逐行计算 units_shipped 时成立(不能发负数的货), 在汇总 / stock_after 里必须原样上报负数。

③ 两段装配里,B 段写没写决定超卖会不会发生。 自检一行:

grep -n "place_reservation" app/*.py    # 有没有【调用点】,不是只看定义

只做 A 段快照时,报告会一切正常,但库存可以被反复卖。

④ 容量类参数先问「单位是什么」。WAVE_CAPACITY 限行数、MAX_OPEN_WAVES 限波数、WAVE_MIN_LINE_UNITS 是逐行平均下限。 三个数字都叫「容量/上限」,口径完全不同。把行数上限当成件数上限,就是「大单行把每一波都提前切断」。 同理:idle_timeout ≠ read_timeout、maxSurge ≠ replicas、连接池 max ≠ 应用并发上限。

⑤ prev_xxx 赋值了但没被读,就是死变量。prev_tier = line["sla_tier"] 写了却没有任何 if 读它 ——「打算按 sla 切波」和 「真的按 sla 切波」差一整个 if。查法:grep -c "prev_" file 看出现次数是否只有赋值没有使用。

⑥ 空判据表 = 没有检查。 BAD_FIELDS = () 时 for 一次都不循环, 代码看着像有检查,其实在空转。判据必须有内容,且必须是外部能自己算一遍的事实 (契约 + 输入事实 + 报告),不能是「自己的字段比自己」。

⑦ 退出码要分层,「判不了」按「有问题」处理(fail closed)。0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用。 except OSError: return EXIT_OK 这种写法让「拿不到数据」变成「体检合格」。

⑧ 修 bug 只解决这一次,修闸门才解决下一次。 只修可用量和 B 段,探针就变绿了;但不修健康检查,下次同类错误依然四个绿灯全亮。

⑨ 跑验收脚本前先读它的退出码语义。ops/repro.sh 的退出码就是探针退出码,问题态 rc=1 是预期的复现信号, 用 ; echo rc=$? 接,别用 &&。ops/negative.sh 是问题态专用:它断言基线为红、 bug 注入靠字符串 replace(修复后锚点不存在 → 无声失败 → 误报「闸门没牙」)、 且 restore 只回拷它备份过的文件,跑完可能把容器里的代码改成「带毒」状态。 修复后的「闸门有没有牙」请用等价回滚(临时改 app/ → 重建镜像 → 跑 verify → 还原)自己验一遍。

自查口诀:我这个量是「事实」还是「缓存」?负数被我抹平了吗?B 段真的落库了吗? 这个容量限的是行还是件?prev_xxx 真的被读了吗?判据表是不是空的? 拿不到数据时我判通过还是不通过?把题眼回滚之后,我的闸门会不会转红?

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-27-0001-wms-0

分享这篇文章