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

库存对账器算错可用量:移库单被当成可用库存 + 补货量 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 当成在途、 补货量向上取整 ceil vs floor、补货点 <= 写成 <、 分仓可用量取 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 给补货系统和看板。

运营侧两个抱怨:

  1. 明明有几行 reserved 大于 on_hand(超卖/负库存),对账器却说「没问题」;
  2. 补货量算出来总是偏小: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)

  1. 可用量混入移库单:available = on_hand + moved_units - reserved。 一条 moved 的移库单只是「货从 A 仓搬到 B 仓」,两个仓的 on_hand 都已经在快照里了, 再把它加一遍 = 同一批货记两次。SKU-1001@WH-A 因此报 180(实际 100)。 正确口径:available = on_hand - reserved,与移库单完全无关。
  2. 补货量用 floor 取整:math.floor(shortage/lot)*lot 向下取整 → 缺 100 只报 90, 永远少订一截。契约要求 ceil 向上取整到 lot_size 整数倍。
  3. 补货点判据 < 应为 <=:available == reorder_point 时不算 low, 刚好卡在补货点的那行被漏掉(SKU-1010)。契约 §4 明确是 <=。
  4. cancelled 单计入在途:status in ("in_transit","cancelled")。 作废单据按 0 计,SKU-1009 凭空多出 25 在途。
  5. 分仓可用量取 max 而不是求和:slot["available"] = max(slot["available"], avail), WH-A 报 180(其实该是 255)。聚合口径是 Σ。
  6. counts.rows 报了仓数:len(by_wh) = 2,应为快照数据行数 13。
  7. ok 写死 True:ok := len(rejects)==0 and 无 negative 行,写死后永远「没问题」。

B. 健康检查没有牙(app/health.py)

  1. BAD_FIELDS = () —— 判据表是空的,for field in BAD_FIELDS 循环 0 次,issues 恒空; main() 更是无条件 return EXIT_OK。所以任何报告都能拿 rc=0。 而且它只做「字段在不在」的存在性检查,就算判据表填满也不够, 必须按契约独立重算再逐字段对照(复用 app.recon 的输出 = 自证健康)。

C. 契约 fail-open(app/contract.py)

  1. 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.runapp.health
删掉 inventory.envrc=3rc=3
INVENTORY_LOT_SIZE=0rc=3rc=3
INVENTORY_MAX_ORDER_QTY=abcrc=3rc=3

六、经验与避坑

  1. 「可用量」和「在途量」是两个独立口径。 见到 available = on_hand ± xxx, 先问一句「xxx 有没有已经被 on_hand 体现过」。移库单 / 调拨单 / 已到货单据 都不能再叠进可用量,否则同一批货记两次。
  2. 取整方向是业务决策,不是实现细节。 补货量必然 ceil(宁可多订不可缺货), 预算/分摊类才会 floor。而且「取整 → 压顶 → 零单门槛」的顺序也是契约的一部分, 任意两步交换结果就不同。
  3. 边界比较符要拿真实数据比一遍。 <= 和 < 差一条记录, 而这条记录往往正是「刚好卡在阈值上」的那条 —— 也是最容易被漏、最难被发现的那条。 用 available == reorder_point 的样例数据专门压一次。
  4. 别信被测代码的健康检查。 判据表为空 + 无条件 return 0 是经典假绿: 报错不报错都返回 0,等于没有检查。正确做法是独立重算, 并且要覆盖 0 / 2 / 3 三档语义:0 干净、2 有真问题、3 判不了。
  5. 3 这一档最容易被吃掉。 「配置读不到就用默认值」看着很稳,其实是最坏的设计: 系统带着一套没人知道的参数继续跑,还报绿。判不了 ≠ 没问题,必须 fail-closed。
  6. 负向验证是验证的验证。 修完不能只看「变绿了」, 要把每个题眼逐一回滚,确认每一处都能让闸门转红(ops/negative.sh:17 条)。 否则你只是证明「现在的输出长得对」,没证明「闸门有牙」。
  7. Docker 里改代码不重建 = 白改。 COPY app/ /srv/app/ 是快照。 迭代用 docker compose cp app/. <svc>:/srv/app/,验收必须 docker compose down -v && up -d --build。
  8. __pycache__ 会让你怀疑人生。 代码明明改了、行为没变,先 find app -name __pycache__ -exec rm -rf {} + 再复测。
  9. 测退出码别接管道。 cmd | head -2; echo $? 拿到的是 head 的退出码, 一度让我误判「fail-closed 没生效」,实际是 rc=3 被 head 吞了。
  10. 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             # 验收口径(只读)

版权声明

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

分享这篇文章