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

@ -75,6 +75,7 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit)
prod = prod_q.first()
if prod:
OutboundService._assert_scan_allocatable(prod)
res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map)
res['price'] = get_price(prod, 'stock_product')
return res
@ -87,6 +88,7 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit)
semi = semi_q.first()
if semi:
OutboundService._assert_scan_allocatable(semi)
res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map)
res['price'] = 0
return res
@ -99,15 +101,19 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit)
buy = buy_q.first()
if buy:
OutboundService._assert_scan_allocatable(buy)
res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map)
res['price'] = get_price(buy, 'stock_buy')
return res
# 查询维修单表 (按SKU或序列号查询,排除已出库状态)
# 查询维修单表 (按SKU或序列号查询)
# ★ 维修单不走库存 status 体系,用自身的 repair_status 做同等准入判断:
# 已出库(货已交回客户)、报废转出(实物已销毁)都不该再被扫出库。
# 原实现只排除 '已出库',报废转出的维修件仍可被扫走。
repair = TransRepair.query.filter(
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status != '已出库'
TransRepair.repair_status.notin_(['已出库', '报废转出'])
).first()
if repair:
res = {
@ -134,6 +140,27 @@ class OutboundService:
return None
@staticmethod
def _assert_scan_allocatable(item):
"""
扫码准入校验:状态异常的实物一律拦在扫码环节。
★ 为什么扫码就拦而不只在提交时拦verify_scanned 已有一道):
提交时的校验是整单粒度的,报错只说「物料 X 状态异常」,工人得自己
在一整单里找是哪一件。扫码即拦能立刻定位到手上这一件。
两道校验用同一个 is_allocatable() 判定,语义不会分叉。
★ 与分配器的关系:分配器只决定"申请时能不能把这行算进候选"
而冻结可能发生在「申请已通过、工人正在扫」的窗口内 ——
扫码这道是覆盖该窗口的必要补充。
"""
from app.services.inventory_reservation import is_allocatable
if not is_allocatable(item):
# status 为空时给出可读文案,避免报出 "当前为 " 这种半截话
status = (getattr(item, 'status', None) or '').strip() or '未设置'
raise ValueError(f"物料状态异常(当前为 {status}),禁止出库")
@staticmethod
def _format_scan_result(item, table_name, reserved_map=None):
"""
@ -216,21 +243,37 @@ class OutboundService:
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 审批单相关逻辑
# ==================================================================
# ★ 强制按单出库Fail-Closed所有出库必须关联有效的出库申请单
#
# 背景:改造前 request_id 为空时会跳过预占再平衡,落到所谓「散单」
# 分支,而该分支的库存扣减逻辑从未落地 —— 旧注释点名的
# _apply_reservation_override() 在全仓并不存在。实测后果:散单出库
# 只写 TransOutbound 台账stock_quantity / available_quantity
# 分毫不动,同一批货可被反复出库而不减库存。
#
# 前端已彻底移除散单入口views/outbound/create.vue 在未选单时把
# 扫码框整块 disabled但 HTTP 接口仍可被直接调用绕过 —— 故在此
# 后端硬阻断,防 API 级绕过。
#
# ★ 强制按单同时保证库存扣减路径唯一:
# 申请预占 → 扫码校验 → restore_then_deduct()
# status 硬隔离(仅有「在库」可被分配)也依赖这条路径才全程有效。
# ==================================================================
request_id = data.get('request_id')
approval = None
if request_id:
# 根据 request_id 查询审批单
approval = OutboundApproval.query.get(request_id)
if not approval:
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
current_status = status_map.get(approval.status, str(approval.status))
raise ValueError(
f"关联的审批单状态不允许出库 (当前状态: {current_status})"
f"仅已通过的审批单方可执行出库"
)
if not request_id:
raise ValueError("非法操作:所有出库必须关联有效的出库申请单")
approval = OutboundApproval.query.get(request_id)
if not approval:
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
current_status = status_map.get(approval.status, str(approval.status))
raise ValueError(
f"关联的审批单状态不允许出库 (当前状态: {current_status})"
f"仅已通过的审批单方可执行出库"
)
model_map = {
'stock_buy': StockBuy,
@ -242,18 +285,25 @@ class OutboundService:
track_notifications = []
# ==================================================================
# ★ Phase 3预占再平衡(仅针对关联审批单的出库)
# ★ Phase 3预占再平衡 —— 库存扣减的唯一路径
#
# 关联审批单时,申请阶段已把货预占在「申请时选定的批次」上。
# 工人实际扫的可能是同物料的**另一个批次**(物理覆盖),因此这里:
# 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是
# 同物料的**另一个批次**(物理覆盖),因此这里:
# 1. 校验实扫的身份/数量未超出批准范围base_id 主键,允许换批次)
# 2. 释放全部预占
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
# 之后主循环只写 TransOutbound 流水,不再重复扣库存。
#
# 无关联审批单(散单)时跳过,走原有逐行扣减逻辑。
# ★ 本段已改为**无条件执行**:上方强制 request_id 必填approval 恒非
# None。原「无关联审批单散单时跳过走原有逐行扣减逻辑」的分支
# 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓
# 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。
# ==================================================================
if approval is not None:
# ★ 自带 rollbackrestore_then_deduct() 先释放预占、后校验可用量,
# 中途抛错时 session 已带脏改动。它自身不 commit若此处不兜底
# 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持
# status=1 可重试(或走撤回/驳回),不会产生半扣留状态。
try:
from app.services.inventory_reservation import (
verify_scanned, restore_then_deduct,
)
@ -274,6 +324,9 @@ class OutboundService:
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
except Exception:
db.session.rollback()
raise
try:
for item in items:
@ -347,10 +400,10 @@ class OutboundService:
)
db.session.add(new_record)
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval:
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
# ★ 出库成功后更新审批单状态为"已完成"
# approval 恒非 None上方已强制 request_id 必填),无需再判空
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
db.session.commit()
@ -767,13 +820,21 @@ class OutboundService:
grouped_map[ono]['total_amount'] += subtotal
returned = float(d.returned_quantity or 0)
grouped_map[ono]['items'].append({
# ★ 退回功能所需:前端「退回」按钮要把本字段回传给
# POST /api/v1/inbound/stock/return-from-outbound 的 outbound_id。
# 注意它是**出库明细行**的主键,不是出库单号。
'id': d.id,
'sku': d.sku,
'name': item_name,
'spec_model': item_spec,
'category': item_cat,
'material_type': item_type,
'quantity': qty,
# ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行
'returned_quantity': returned,
'returnable_quantity': qty - returned,
'unit_price': price,
'subtotal': subtotal,
'batch_sn': batch_sn,