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:
@ -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}】不在该申请单的批准明细中,禁止出库"
|
||||
|
||||
Reference in New Issue
Block a user