库存对账器算错可用量:移库单被当成可用库存 + 补货量 floor 取整 + 健康检查假绿(Python / CSV / Docker Compose)
日期:2026-10-19 | 分支:
2026-10-19-1000| 第 135 题 技术栈:Python 3.11 / csv / json / Docker Compose / 外部独立探针 关键词:库存对账 inventory reconciliation、available = on_hand - reserved、 移库单 transfers 被算进可用量、moved重复计货、cancelled当成在途、 补货量向上取整ceilvsfloor、补货点<=写成<、 分仓可用量取max冒充求和、counts.rows报仓数、ok写死 true、 空判据表BAD_FIELDS = ()、健康检查恒return 0、假绿 false green、 契约 fail-open / fail-closed、退出码 0/2/3 分层、__pycache__旧代码
一、问题背景
一套多仓库存对账器,每 10 分钟读库存快照 stock.csv + 移库单 transfers.csv,
产出 inventory-report.json 给补货系统和看板。
运营侧两个抱怨:
- 明明有几行
reserved大于on_hand(超卖/负库存),对账器却说「没问题」; - 补货量算出来总是偏小:
SKU-1002可用量已经是 0,缺 100 却只报 90; 而「刚好等于补货点」的SKU-1010(available = 20 = INVENTORY_REORDER_POINT) 被判成ok,根本没进补货提醒。
更麻烦的是对账器自己的绿灯全不作数:
{"counts": {"rejects": 2, "rows": 2, "skus": 10}, "ok": true, "totals": {...}} // rc=0,只证明进程没崩
{"degraded": false, "issues": []} // 空判据表 + 恒 return 0
唯一说真话的是外部独立探针 client/inventory_probe.py(只读契约 + 快照 + 移库单 + 报告,
不读报告任何自述字段,按契约全量重算):不一致 19 条。
二、复现步骤
cd scenario/2026-10-19-1000
docker compose up -d --build
docker compose exec -T inventory bash # 非交互环境必须加 -T
# 1) 问题态:跑一轮 + 摘要 + 逐行 + 健康检查 + 独立探针
bash ops/repro.sh ; echo "repro rc=$?" # RESULT: BAD(不一致 19 条),rc=1
# 2) 逐条验收(期望值按契约手算写死)
bash ops/verify.sh ; echo "verify rc=$?" # PASS=31 FAIL=25 / RESULT: BAD
# 3) 负向验证:把每个题眼逐一回滚,看闸门会不会转红
bash ops/negative.sh ; echo "neg rc=$?" # 参考解读数:NPASS=17 NFAIL=0
ops/repro.sh的退出码 = 探针退出码,问题态是1。写成bash ops/repro.sh && ...会让后面全被中断,用; echo "rc=$?"接住。
问题态关键输出:
counts {"rejects":2,"rows":2,"skus":10}
totals {"available":707,"reorder_qty":593,"replenish_qty":570}
by_warehouse {"WH-A":{"available":180,...},"WH-B":{"available":150,...}}
ok True
pending {"SKU-1004":120,"SKU-1006":40,"SKU-1007":60,"SKU-1009":25,"SKU-1010":35}
SKU-1001 WH-A on_hand=120 reserved=20 available=180 status=ok replenish_qty=0
SKU-1002 WH-A on_hand=8 reserved=8 available=90 status=ok replenish_qty=0
SKU-1003 WH-B on_hand=12 reserved=20 available=-8 status=negative replenish_qty=100
SKU-1008 WH-A on_hand=6 reserved=1 available=5 status=low replenish_qty=90
SKU-1010 WH-A on_hand=20 reserved=0 available=20 status=ok replenish_qty=0
{"degraded": false, "engine": "inventory-recon", "issues": []}
health rc=0
正确口径(契约权威)应得:
counts {"rejects":2,"rows":13,"skus":10}
totals {"available":457,"reorder_qty":773,"replenish_qty":800}
by_warehouse {"WH-A":{"available":255,"replenish_qty":380,"rows":7},"WH-B":{"available":202,"replenish_qty":420,"rows":6}}
reorder_now ["SKU-1002","SKU-1003","SKU-1005","SKU-1006","SKU-1008","SKU-1009","SKU-1010"]
ok False
pending {"SKU-1004":120,"SKU-1006":40,"SKU-1007":60,"SKU-1010":35}
SKU-1010 WH-A available=20 status=low replenish_qty=80
{"degraded": true, ..., "issues": ["ok=false:账实不符或存在坏数据"]}
health rc=2
三、根本原因
9 个独立错误,分三类:
A. 业务口径算错(app/recon.py)
- 可用量混入移库单:
available = on_hand + moved_units - reserved。 一条moved的移库单只是「货从 A 仓搬到 B 仓」,两个仓的on_hand都已经在快照里了, 再把它加一遍 = 同一批货记两次。SKU-1001@WH-A因此报 180(实际 100)。 正确口径:available = on_hand - reserved,与移库单完全无关。 - 补货量用
floor取整:math.floor(shortage/lot)*lot向下取整 → 缺 100 只报 90, 永远少订一截。契约要求ceil向上取整到lot_size整数倍。 - 补货点判据
<应为<=:available == reorder_point时不算low, 刚好卡在补货点的那行被漏掉(SKU-1010)。契约 §4 明确是<=。 cancelled单计入在途:status in ("in_transit","cancelled")。 作废单据按 0 计,SKU-1009凭空多出 25 在途。- 分仓可用量取
max而不是求和:slot["available"] = max(slot["available"], avail),WH-A报 180(其实该是 255)。聚合口径是Σ。 counts.rows报了仓数:len(by_wh)= 2,应为快照数据行数 13。ok写死True:ok := len(rejects)==0 and 无 negative 行,写死后永远「没问题」。
B. 健康检查没有牙(app/health.py)
BAD_FIELDS = ()—— 判据表是空的,for field in BAD_FIELDS循环 0 次,issues恒空;main()更是无条件return EXIT_OK。所以任何报告都能拿 rc=0。 而且它只做「字段在不在」的存在性检查,就算判据表填满也不够, 必须按契约独立重算再逐字段对照(复用app.recon的输出 = 自证健康)。
C. 契约 fail-open(app/contract.py)
FALLBACK默认值 +except OSError: pass:契约读不到就用默认数字接着算, 悄悄换了一套口径。正确姿势是 fail-closed:缺必填 / 非整数 / 口径非法 → 抛错 →rc=3。 「判不了」绝不能报成「没问题」。
四、解决办法
1) app/recon.py
def _available(row: dict) -> int:
return row["on_hand"] - row["reserved"] # 契约 §2,与移库单无关
def _status(available: int, reorder_point: int) -> str:
if available < 0: return "negative"
if available == 0: return "out"
if available <= reorder_point: return "low" # <=,等于补货点就要补
return "ok"
def _replenish(available, status, cfg) -> int:
if status == "ok": return 0
shortage = cfg["INVENTORY_TARGET_LEVEL"] - available
if shortage <= 0: return 0
# 先 ceil 到 lot 整数倍 -> 再 max 压顶 -> 再 min 零单门槛(顺序是契约的一部分)
qty = int(math.ceil(shortage / cfg["INVENTORY_LOT_SIZE"])) * cfg["INVENTORY_LOT_SIZE"]
qty = min(qty, cfg["INVENTORY_MAX_ORDER_QTY"])
return 0 if qty < cfg["INVENTORY_MIN_ORDER_QTY"] else qty
_pending():只tr["status"] == "in_transit"计入(moved已到货、cancelled按 0)。build_report():删掉moved聚合;slot["available"] += avail;"rows": len(rows);"ok": len(rejects) == 0 and all(r["status"] != "negative" for r in rows)。
2) app/health.py(判据表非空 + 独立重算 + rc 0/2/3)
BAD_FIELDS = ("ok","rows","by_warehouse","totals","counts","pending","reorder_now","rejects")
ROW_FIELDS = ("available","status","replenish_qty")
def _reference(cfg): # 直接读 csv 按契约重算,不 import app.recon
...
def _diff(rep, exp):
issues = [f"missing:{f}" for f in BAD_FIELDS if f not in rep]
if exp["ok"] is False: # 有真问题 -> rc=2
issues.append("ok=false:账实不符或存在坏数据")
...
return issues
def main():
try: cfg = contract.load()
except contract.ContractError as exc: ... return EXIT_UNKNOWN # 3
if not REPORT.exists(): ... return EXIT_BAD # 2
...
return EXIT_BAD if issues else EXIT_OK
3) app/contract.py(fail-closed)
去掉 FALLBACK / except OSError: pass,改为读不到 / int() 失败 / 缺必填 /
LOT_SIZE<=0 / TARGET_LEVEL<=0 / 门槛为负 / MAX < MIN 一律抛 ContractError,
app/run.py 已有的 except ContractError: return 3 直接生效。
五、验证
cd scenario/2026-10-19-1000
docker compose down -v && docker compose up -d --build # 干净重建,确保跑的是新代码
docker compose exec -T inventory bash ops/repro.sh ; echo "repro rc=$?"
docker compose exec -T inventory bash ops/verify.sh ; echo "verify rc=$?"
docker compose exec -T inventory bash ops/negative.sh ; echo "neg rc=$?"
counts {"rejects":2,"rows":13,"skus":10}
totals {"available":457,"reorder_qty":773,"replenish_qty":800}
by_warehouse {"WH-A":{"available":255,"replenish_qty":380,"rows":7},"WH-B":{"available":202,"replenish_qty":420,"rows":6}}
reorder_now ["SKU-1002","SKU-1003","SKU-1005","SKU-1006","SKU-1008","SKU-1009","SKU-1010"]
ok False
pending {"SKU-1004":120,"SKU-1006":40,"SKU-1007":60,"SKU-1010":35}
{"degraded": true, "engine": "inventory-recon", "issues": ["ok=false:账实不符或存在坏数据"]}
health rc=2
RESULT: OK
probe rc=0
repro rc=0
PASS=56 FAIL=0
RESULT: OK
NPASS=17 NFAIL=0
RESULT: OK(闸门有牙)
契约 fail-closed 边界实测(临时改 conf/inventory.env,测完还原):
| 场景 | app.run | app.health |
|---|---|---|
删掉 inventory.env | rc=3 | rc=3 |
INVENTORY_LOT_SIZE=0 | rc=3 | rc=3 |
INVENTORY_MAX_ORDER_QTY=abc | rc=3 | rc=3 |
六、经验与避坑
- 「可用量」和「在途量」是两个独立口径。 见到
available = on_hand ± xxx, 先问一句「xxx 有没有已经被on_hand体现过」。移库单 / 调拨单 / 已到货单据 都不能再叠进可用量,否则同一批货记两次。 - 取整方向是业务决策,不是实现细节。 补货量必然
ceil(宁可多订不可缺货), 预算/分摊类才会floor。而且「取整 → 压顶 → 零单门槛」的顺序也是契约的一部分, 任意两步交换结果就不同。 - 边界比较符要拿真实数据比一遍。
<=和<差一条记录, 而这条记录往往正是「刚好卡在阈值上」的那条 —— 也是最容易被漏、最难被发现的那条。 用available == reorder_point的样例数据专门压一次。 - 别信被测代码的健康检查。 判据表为空 + 无条件
return 0是经典假绿: 报错不报错都返回 0,等于没有检查。正确做法是独立重算, 并且要覆盖0 / 2 / 3三档语义:0干净、2有真问题、3判不了。 3这一档最容易被吃掉。 「配置读不到就用默认值」看着很稳,其实是最坏的设计: 系统带着一套没人知道的参数继续跑,还报绿。判不了 ≠ 没问题,必须 fail-closed。- 负向验证是验证的验证。 修完不能只看「变绿了」,
要把每个题眼逐一回滚,确认每一处都能让闸门转红(
ops/negative.sh:17 条)。 否则你只是证明「现在的输出长得对」,没证明「闸门有牙」。 - Docker 里改代码不重建 = 白改。
COPY app/ /srv/app/是快照。 迭代用docker compose cp app/. <svc>:/srv/app/,验收必须docker compose down -v && up -d --build。 __pycache__会让你怀疑人生。 代码明明改了、行为没变,先find app -name __pycache__ -exec rm -rf {} +再复测。- 测退出码别接管道。
cmd | head -2; echo $?拿到的是head的退出码, 一度让我误判「fail-closed 没生效」,实际是rc=3被head吞了。 set -e+ 预期非 0 退出码 = 脚本半路中断。 用cmd || RC=$?接住, 验收脚本里最好自己set +e(如ops/lib.sh的做法)。
七、相关文件
scenario/2026-10-19-1000/
├── SOLUTION.md # 完整操作过程记录
├── docs/inventory-contract.md # 权威口径(只读)
├── conf/inventory.env # 权威数值(只读)
├── conf/engine.env # 引擎构建期默认值,不是契约,别拿来判定
├── inventory/{stock,transfers}.csv
├── app/{contract,inventory,recon,run,health}.py # 改了 contract / recon / health
├── client/inventory_probe.py # 外部独立探针(只读)
└── ops/{lib,repro,verify,negative}.sh # 验收口径(只读)
