fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数

出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占,
防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的
上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次:

  · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」
  · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失
  · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」

改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占
不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验
的数字精确相等,是同一口径而非近似。

后端:
- inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具
- outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives
- outbound_service.py 的 _format_scan_result 返回归一化可用量

两道门禁:
- biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被
  出库页与借库页共用),否则借库单 ID 会命中另一张出库单
- 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后
  items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会
  二次回加 → 真超卖(现有 3 张已完成单据即属此形态)

不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
This commit is contained in:
yueli
2026-09-11 10:34:00 +08:00
parent b8561f69f7
commit cbcedecba2
3 changed files with 196 additions and 16 deletions

View File

@ -54,6 +54,63 @@ def filter_item_by_permissions(item_dict, user_permissions):
return item_dict return item_dict
# ==============================================================================
# 辅助函数:本单预占索引(扫码阶段回加「本单自己锁掉的货」)
# ==============================================================================
# 扫码页面上工人应当能以「实时可用量 + 本单预占量」为上限 —— 本单自己锁定
# 的货当然扫得进去。若直接用实时 available_quantity,被本单占满的行会显示 0,
# 工人根本扫不进去(见 create.vue / borrow.vue 的扫码校验)。
#
# ★ 两道必须守住的门禁
# 1) biz_type 区分单据表。outbound_approval 与 borrow_approval 是两张独立
# 表、ID 空间独立,而 /scan 与 /alternatives 被出库页与借库页**共用**。
# 只传 request_id 会让借库单 ID 命中另一张出库单,把别人的预占回加到
# 本行 —— 后端最终仍会拦住(不会真超卖),但表现为「扫完提交被拒」,
# 比现状更难排查。
# 2) status ∈ {0, 1}。set_items() 只在创建时调用,执行(3)/驳回(2)/完结(4)
# 后 items_json 里的 reserved=True / allocated_qty 原样保留,而
# release_reserved() 早已把库存归还 —— 此时再回加就是凭空多出一份
# 可用量,且后端也会放行(该 available 真实存在)→ 真超卖。
# 反之也不能写死 ==1:预占在**提交申请时**就发生(status=0),而两个
# 审批页默认筛选的就是待审批单。
#
# 任何异常一律静默降级为「不回加」:这是读侧辅助接口,报错会直接阻断现场
# 作业;降级方向是 fail-closed(有效量偏小),最坏提示「库存不足」,绝不超卖。
def _own_reserved_index(biz_type, request_id):
"""
本单预占索引 {(source_table, stock_id): 预占量}。
不满足状态门禁 / 单据不存在 / 参数非法时返回 {}(降级为不回加)。
注意返回的是**实时重算**的结果,不做任何累加,因此草稿反复刷新幂等。
"""
if not request_id:
return {}
if (biz_type or '').strip().lower() == 'borrow':
from app.models.borrow import BorrowApproval as _Approval
else:
# 缺省按出库兜底,兼容未传 biz_type 的旧调用方
from app.models.outbound import OutboundApproval as _Approval
try:
approval = _Approval.query.get(int(request_id))
except (TypeError, ValueError):
return {}
if not approval:
current_app.logger.warning(
f"[reservation] 预占回加降级:单据不存在 biz_type={biz_type} id={request_id}"
)
return {}
# ★ 二次回加门禁:仅待审批(0)/已通过(1)的单据,其预占才真实存在
if approval.status not in (0, 1):
return {}
from app.services.inventory_reservation import reserved_index
return reserved_index(approval.get_items())
# -------------------------------------------------------- # --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表) # 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=... # GET /api/v1/outbound/scan?barcode=...
@ -66,13 +123,20 @@ def scan_barcode():
if not barcode: if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400 return jsonify({'code': 400, 'msg': '请提供条码'}), 400
# ★ 本单预占回加:biz_type 区分出库/借库两张审批单,缺一不可
biz_type = (request.args.get('biz_type') or 'outbound').strip()
request_id = request.args.get('request_id', type=int)
reserved_map = _own_reserved_index(biz_type, request_id)
try: try:
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格) # 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
result = OutboundService.get_stock_by_barcode(barcode) result = OutboundService.get_stock_by_barcode(barcode, reserved_map)
if result: if result:
# ★ Fail-Closed: 扫码响应剥离价格字段 # ★ Fail-Closed: 扫码响应剥离价格字段
result.pop('price', None) result.pop('price', None)
# 预占是否生效:false 表示已降级为实时可用量(前端可据此提示)
result['reservation_applied'] = bool(reserved_map)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': '扫描成功', 'msg': '扫描成功',
@ -368,11 +432,22 @@ def _allocate_bom_requirements(requirements, company_limit,
@jwt_required() @jwt_required()
def get_stock_alternatives(): def get_stock_alternatives():
""" """
查询某物料的全部可替代库位(available_quantity > 0)。 查询某物料的全部可替代库位。
Query: base_id(必填)、source_table / stock_id(可选,用于标注推荐行) Query: base_id(必填)、source_table / stock_id(可选,用于标注推荐行)
biz_type / request_id(可选,出库/借库单据;用于回加本单预占)
★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单在该行的预占量,
因此过滤条件也相应放宽为「实时可用量 > 0 或 本单预占了该行」。
否则被本单占满的行会从列表里凭空消失(实时可用量为 0),工人看不到
自己明明锁定的批次。实时原值另以 raw_available_quantity 返回备查。
别人的预占不回加,防超卖能力不丢。
Returns: { items: [{stock_id, source_table, warehouse_location, Returns: { items: [{stock_id, source_table, warehouse_location,
available_quantity, is_locked, typeLabel, sku, batch_number}] } available_quantity, raw_available_quantity,
reserved_quantity, is_own_reserved,
is_locked, typeLabel, sku, batch_number}],
total_available }
""" """
try: try:
base_id = request.args.get('base_id', type=int) base_id = request.args.get('base_id', type=int)
@ -385,11 +460,22 @@ def get_stock_alternatives():
except (TypeError, ValueError): except (TypeError, ValueError):
prefer_stock_id = 0 prefer_stock_id = 0
# ★ 本单预占回加(biz_type 区分出库/借库两张审批单)
biz_type = (request.args.get('biz_type') or 'outbound').strip()
request_id = request.args.get('request_id', type=int)
reserved_map = _own_reserved_index(biz_type, request_id)
# 按库存表分组本单预占的 stock_id,供 OR 过滤使用
own_ids = {}
for (st, sid) in reserved_map:
own_ids.setdefault(st, set()).add(sid)
from app.utils.decorators import get_current_company_filter from app.utils.decorators import get_current_company_filter
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from sqlalchemy import or_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
company_limit = get_current_company_filter() company_limit = get_current_company_filter()
@ -400,10 +486,17 @@ def get_stock_alternatives():
(StockSemi, 'stock_semi', '半成品'), (StockSemi, 'stock_semi', '半成品'),
(StockProduct, 'stock_product', '成品'), (StockProduct, 'stock_product', '成品'),
): ):
q = model.query.filter( # ★ 只给真正能拿的;但本单自己预占的行即使实时可用量为 0 也要给
model.base_id == base_id, # (否则工人看不到自己锁定的批次)。
model.available_quantity > 0, # ★ 只给真正能拿的 # 注意 id 集合为空时不能拼 in_([]):SQLAlchemy 会渲染成恒假
) # 表达式并告警,这里退化为原条件。
condition = model.available_quantity > 0
_ids = own_ids.get(source_table)
if _ids:
condition = or_(condition, model.id.in_(_ids))
q = model.query.filter(model.base_id == base_id, condition)
# 公司隔离作用于整个 query,保持在 OR 之外
if company_limit is not None: if company_limit is not None:
q = q.filter(model.base.has(MaterialBase.company_name == company_limit)) q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
try: try:
@ -415,6 +508,8 @@ def get_stock_alternatives():
continue continue
for s in rows: for s in rows:
raw_avail = float(s.available_quantity or 0)
reserved = float(reserved_map.get((source_table, s.id), 0) or 0)
items.append({ items.append({
'stock_id': s.id, 'stock_id': s.id,
'source_table': source_table, 'source_table': source_table,
@ -422,13 +517,18 @@ def get_stock_alternatives():
'sku': s.sku or '', 'sku': s.sku or '',
'batch_number': getattr(s, 'batch_number', '') or getattr(s, 'serial_number', '') or '', 'batch_number': getattr(s, 'batch_number', '') or getattr(s, 'serial_number', '') or '',
'warehouse_location': getattr(s, 'warehouse_location', '') or '', 'warehouse_location': getattr(s, 'warehouse_location', '') or '',
'available_quantity': float(s.available_quantity or 0), # 有效可用量:本单可拿的上限
'available_quantity': raw_avail + reserved,
'raw_available_quantity': raw_avail,
'reserved_quantity': reserved,
# ★ 本单已锁定该行(分配器可能跨批次拆分,故可能是多行)
'is_own_reserved': reserved > 0,
# ★ 该行是否就是本单锁定的推荐批次 # ★ 该行是否就是本单锁定的推荐批次
'is_locked': (prefer_stock_id and s.id == prefer_stock_id 'is_locked': (prefer_stock_id and s.id == prefer_stock_id
and source_table == prefer_table), and source_table == prefer_table),
}) })
# 排序:推荐行置顶,其余按可用量降序(工人优先看到货最多的库位) # 排序:推荐行置顶,其余按有效可用量降序(工人优先看到货最多的库位)
items.sort(key=lambda x: (not x['is_locked'], -x['available_quantity'])) items.sort(key=lambda x: (not x['is_locked'], -x['available_quantity']))
return jsonify({ return jsonify({
@ -436,6 +536,7 @@ def get_stock_alternatives():
'data': { 'data': {
'items': items, 'items': items,
'total_available': round(sum(i['available_quantity'] for i in items), 4), 'total_available': round(sum(i['available_quantity'] for i in items), 4),
'reservation_applied': bool(reserved_map),
} }
}), 200 }), 200

View File

@ -257,6 +257,60 @@ def release_reserved(items):
return restored return restored
# =============================================================================
# 预占查询(只读)
# =============================================================================
def reserved_index(approval_items):
"""
★ 本单的预占索引:{(source_table, stock_id): 预占总量}
用途:扫码执行阶段,工人应该能以「实时可用量 + 本单自己锁掉的量」为上限
—— 本单预占的货当然应该能扫。别人的预占不回加,防超卖能力不丢。
这与 restore_then_deduct() 的口径精确对齐:后者先 release_reserved()
把本单预占还回池子,再用 _sum_available_for_identity() 校验。
即「释放后的可用总量」恒等于「各行实时可用量 + 本单预占量」之和。
⚠ 调用方必须先做**单据状态门禁**(仅放行 status ∈ {0, 1})。
执行(3)/驳回(2)/完结(4)后 items_json 里的 reserved=True 与
allocated_qty 仍原样保留(set_items 只在创建时调用,
release_reserved 只归还库存、不重写 items_json),
此时库存早已归还,再回加就是凭空多出一份可用量 → 真超卖。
同一 (source_table, stock_id) 在 items_json 中出现多行时**累加**。
"""
index = {}
for it in approval_items or []:
if not it.get('reserved'):
continue
try:
qty = float(it.get('allocated_qty') or 0)
except (TypeError, ValueError):
continue
if qty <= 0:
continue
st = norm_text(it.get('source_table'))
try:
sid = int(it.get('stock_id'))
except (TypeError, ValueError):
continue
if not st:
continue
key = (st, sid)
index[key] = index.get(key, 0.0) + qty
return index
def reserved_qty(approval_items, source_table, stock_id):
"""本单在某库存行上的预占量(reserved_index 的便捷封装)"""
try:
sid = int(stock_id)
except (TypeError, ValueError):
return 0.0
return reserved_index(approval_items).get((norm_text(source_table), sid), 0.0)
# ============================================================================= # =============================================================================
# 执行阶段:身份校验 # 执行阶段:身份校验
# ============================================================================= # =============================================================================

View File

@ -42,9 +42,13 @@ class OutboundService:
return f"OUT-{date_str}-{time_str}-{sequence:04d}" return f"OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod @staticmethod
def get_stock_by_barcode(barcode): def get_stock_by_barcode(barcode, reserved_map=None):
""" """
根据扫码内容查找对应的库存物品,并附带价格信息 根据扫码内容查找对应的库存物品,并附带价格信息
reserved_map: {(source_table, stock_id): 本单预占量},可选。
传入后 available_quantity 返回「有效可用量」
(= 实时值 + 本单预占),见 _format_scan_result。
""" """
if not barcode: if not barcode:
return None return None
@ -71,7 +75,7 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit) .filter(MaterialBase.company_name == company_limit)
prod = prod_q.first() prod = prod_q.first()
if prod: if prod:
res = OutboundService._format_scan_result(prod, 'stock_product') res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map)
res['price'] = get_price(prod, 'stock_product') res['price'] = get_price(prod, 'stock_product')
return res return res
@ -83,7 +87,7 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit) .filter(MaterialBase.company_name == company_limit)
semi = semi_q.first() semi = semi_q.first()
if semi: if semi:
res = OutboundService._format_scan_result(semi, 'stock_semi') res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map)
res['price'] = 0 res['price'] = 0
return res return res
@ -95,7 +99,7 @@ class OutboundService:
.filter(MaterialBase.company_name == company_limit) .filter(MaterialBase.company_name == company_limit)
buy = buy_q.first() buy = buy_q.first()
if buy: if buy:
res = OutboundService._format_scan_result(buy, 'stock_buy') res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map)
res['price'] = get_price(buy, 'stock_buy') res['price'] = get_price(buy, 'stock_buy')
return res return res
@ -115,7 +119,11 @@ class OutboundService:
'material_type': "", 'material_type': "",
'source_table': 'trans_repair', 'source_table': 'trans_repair',
'stock_quantity': 1, 'stock_quantity': 1,
# 维修单不参与预占,有效可用量恒等于实时值;
# 字段形状与 _format_scan_result 对齐,避免前端拿到 undefined
'available_quantity': 1, 'available_quantity': 1,
'raw_available_quantity': 1,
'reserved_quantity': 0,
'batch_number': repair.serial_number or '', 'batch_number': repair.serial_number or '',
'serial_number': repair.serial_number or '', 'serial_number': repair.serial_number or '',
'warehouse_location': repair.customer_location or '', 'warehouse_location': repair.customer_location or '',
@ -127,7 +135,17 @@ class OutboundService:
return None return None
@staticmethod @staticmethod
def _format_scan_result(item, table_name): def _format_scan_result(item, table_name, reserved_map=None):
"""
reserved_map: {(source_table, stock_id): 本单预占量},由调用方按
request_id 查得(见 api/v1/outbound.py 的 _own_reserved_index)。
★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单预占量。
本单自己锁掉的货当然应该能扫 —— 若直接返回实时值,被本单占满的行
会显示 0,工人扫不进去。该值与后端执行阶段
restore_then_deduct() 释放预占后用于校验的数字精确相等。
实时原值另以 raw_available_quantity 返回备查。
"""
base_name = "" base_name = ""
base_spec = "" base_spec = ""
base_cat = "" base_cat = ""
@ -154,7 +172,10 @@ class OutboundService:
base_name = item.base.name base_name = item.base.name
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0 stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
avail_qty = float(item.available_quantity) if item.available_quantity else 0 raw_avail = float(item.available_quantity) if item.available_quantity else 0
# ★ 本单预占回加(别人单子的预占不回加,防超卖能力不丢)
reserved = float((reserved_map or {}).get((table_name, item.id), 0) or 0)
return { return {
'id': item.id, 'id': item.id,
@ -165,7 +186,11 @@ class OutboundService:
'material_type': base_type or "", 'material_type': base_type or "",
'source_table': table_name, 'source_table': table_name,
'stock_quantity': stock_qty, 'stock_quantity': stock_qty,
'available_quantity': avail_qty, # 有效可用量:本单可扫的上限
'available_quantity': raw_avail + reserved,
# 实时原值与本单预占量,备查/展示用
'raw_available_quantity': raw_avail,
'reserved_quantity': reserved,
'batch_number': getattr(item, 'batch_number', ''), 'batch_number': getattr(item, 'batch_number', ''),
'warehouse_location': getattr(item, 'warehouse_location', ''), 'warehouse_location': getattr(item, 'warehouse_location', ''),
'barcode': getattr(item, 'barcode', '') 'barcode': getattr(item, 'barcode', '')