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