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

@ -148,6 +148,11 @@ def scan_barcode():
'msg': '未找到对应的库存记录,请确认条码是否正确'
}), 404
except ValueError as e:
# ★ 业务性拒绝(物料状态异常等):属于「扫到了但按规定不能出」,
# 不是系统故障 —— 返回 400 + 明确文案,便于前端红字提示工人。
# 若落到下方 500 分支,前端只会显示「服务器错误」,工人无从判断。
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500
@ -318,6 +323,16 @@ def _allocate_bom_requirements(requirements, company_limit,
# 按 (base_id, source_table) 归集
rows_by_base = {}
# ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** ——
# 出库申请与借库申请都经 reserve_for_items() 走到这里拿候选行,
# 故在此处加一条即对两者同时生效(报废不走分配器,见 scrap_approval_service
# 规则见 inventory_reservation.allocatable_filter仅「在库」可被分配
# 「冻结」「不良品」以及 status 为 NULL 的行一律不进入候选集。
#
# Fail-Closed 说明:若该条件导致查询异常,下方 except 会 continue 掉整张表,
# 表现为「该物料无库存」而非「放行坏件」,方向上是安全的。
from app.services.inventory_reservation import allocatable_filter
for model, source_table, type_label, type_key in (
(StockBuy, 'stock_buy', '采购件', 'material'),
(StockSemi, 'stock_semi', '半成品', 'semi'),
@ -327,6 +342,7 @@ def _allocate_bom_requirements(requirements, company_limit,
q = model.query.filter(
model.base_id.in_(base_ids),
model.available_quantity > 0, # ★ 只取真正可用的行
allocatable_filter(model), # ★ 硬隔离:非「在库」一律不出货
)
if company_limit is not None:
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))