feat(stock): status 硬隔离与出库通道封堵

激活库存表长期「只写不读」的 status 列,使其成为分配准入的硬门槛。

- inventory_reservation: 新增状态语义单一事实来源(STOCK_STATUS_*/
  allocatable_filter/is_allocatable);verify_scanned 增加状态准入校验
  (覆盖出库与借库两条提交路径)
- outbound: _allocate_bom_requirements 过滤条件加 allocatable_filter,
  非「在库」一律不进入候选集;扫码路由把 ValueError 转为 400
- outbound_service: create_outbound_batch 强制 request_id 必填。原先无单
  时会跳到所谓「散单」分支,而该分支的扣减逻辑从未落地(注释点名的
  _apply_reservation_override 全仓不存在),实测只写台账不扣库存,
  同一批货可被反复出库;get_stock_by_barcode 接入扫码准入;维修单扫码
  补排除「报废转出」;预占再平衡块补 rollback 兜底
- fill_missing_stock_status.sql: 把存量行刷回「在库」。该过滤是
  Fail-Closed 的,不先洗刷会让全库无法出库

破坏性变更:不传 request_id 的出库请求现在会被拒绝。
This commit is contained in:
yueli
2026-09-16 15:45:02 +08:00
parent ffd3e2652c
commit fbc9296056
4 changed files with 312 additions and 25 deletions

View File

@ -81,6 +81,60 @@ def identity_label(key):
return f"{key[1]}({key[2] or '-'})"
# =============================================================================
# 库存状态门槛(硬隔离)
# =============================================================================
# ★ 背景:三张库存表(stock_buy / stock_semi / stock_product)建表起就带
# status 列,但历史实现**只写不读** —— status 仅在入库时写一次 '在库',
# 此后全仓再无任何代码更新它;分配器更是只看 available_quantity。
# 后果:被标成「冻结 / 不良品」的库存行,只要可用量不为 0 就会被正常
# 分配出货,坏件因此可以反复流出。
#
# 此处把 status 变成**真正的准入门槛**:只有白名单内的状态才参与分配。
# 配套两件事,缺一不可:
# · 历史数据洗刷 —— db_migrations/fill_missing_stock_status.sql
# (存量行的 status 若为空/异常,上线本门槛后会集体无法出库)
# · 状态变更入口 —— POST /api/v1/inbound/stock/<id>/change-status
# (否则状态只能靠手工改库,逆向物流无入口)
STOCK_STATUS_IN_STOCK = '在库' # ★ 唯一允许被分配出货的状态
STOCK_STATUS_FROZEN = '冻结' # 盘查/争议期间临时锁定,货还在但要先查清
STOCK_STATUS_DEFECTIVE = '不良品' # 坏件:待维修或待报废,绝不允许再发出
# 允许写入库存行的状态全集(变更接口据此校验,防止脏值从接口进入)
VALID_STOCK_STATUSES = (
STOCK_STATUS_IN_STOCK,
STOCK_STATUS_FROZEN,
STOCK_STATUS_DEFECTIVE,
)
# 「可被分配」的状态白名单。
# ★ 用元组而非单值比较:将来若要放行更多状态(如「待检」),只改这里一处。
ALLOCATABLE_STATUSES = (STOCK_STATUS_IN_STOCK,)
def allocatable_filter(model):
"""
库存行的「可被分配出货」SQL 条件,供分配器拼进 WHERE。
★ Fail-Closed:status 为 NULL 的行**不会**被选中(NULL IN (...) 求值为
NULL,非真)。这正是我们要的语义 —— 状态不明的货宁可不出,但代价是
上线前必须先把存量数据洗刷干净,否则全库无法出库。
洗刷脚本见 db_migrations/fill_missing_stock_status.sql。
"""
return model.status.in_(ALLOCATABLE_STATUSES)
def is_allocatable(row):
"""
单行版判断:该库存记录当前是否可被分配。
供扫码(get_stock_by_barcode)与执行阶段(restore_then_deduct)等
不走 SQL 过滤的路径复用,保证全链路用的是**同一套**状态语义。
"""
return norm_text(getattr(row, 'status', None)) in ALLOCATABLE_STATUSES
# =============================================================================
# 库存行动态解析
# =============================================================================
@ -384,6 +438,22 @@ def verify_scanned(scanned_items, approved_items):
key = stock_identity(row)
label = identity_label(key)
# ★ 状态准入(最终防线):状态异常的实物整单拒绝。
#
# 与分配器 allocatable_filter()、扫码入口 _assert_scan_allocatable()
# 共用同一个 is_allocatable() 判定,三处语义不会分叉。
#
# 这道覆盖的是「申请已通过 → 工人正在扫」这段窗口内被冻结/标不良的
# 情形 —— 分配器管不到(那是申请时刻),扫码入口也可能被绕过
# (前端可被绕过、草稿可陈旧)。这里是写库前的最后一道。
#
# 抛错即整单回滚(调用方负责),预占由 restore_then_deduct 的调用方
# 统一处理 —— 实际语义见 create_outbound_batch 里的 rollback 兜底:
# 单据保持 status=1,可重试或走撤回/驳回,不会留下半扣留状态。
if not is_allocatable(row):
status = (getattr(row, 'status', None) or '').strip() or '未设置'
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
if key not in approved_idx:
raise ValueError(
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"