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:
@ -42,9 +42,13 @@ class OutboundService:
|
||||
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
|
||||
|
||||
@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:
|
||||
return None
|
||||
@ -71,7 +75,7 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
prod = prod_q.first()
|
||||
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')
|
||||
return res
|
||||
|
||||
@ -83,7 +87,7 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
semi = semi_q.first()
|
||||
if semi:
|
||||
res = OutboundService._format_scan_result(semi, 'stock_semi')
|
||||
res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map)
|
||||
res['price'] = 0
|
||||
return res
|
||||
|
||||
@ -95,7 +99,7 @@ class OutboundService:
|
||||
.filter(MaterialBase.company_name == company_limit)
|
||||
buy = buy_q.first()
|
||||
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')
|
||||
return res
|
||||
|
||||
@ -115,7 +119,11 @@ class OutboundService:
|
||||
'material_type': "",
|
||||
'source_table': 'trans_repair',
|
||||
'stock_quantity': 1,
|
||||
# 维修单不参与预占,有效可用量恒等于实时值;
|
||||
# 字段形状与 _format_scan_result 对齐,避免前端拿到 undefined
|
||||
'available_quantity': 1,
|
||||
'raw_available_quantity': 1,
|
||||
'reserved_quantity': 0,
|
||||
'batch_number': repair.serial_number or '',
|
||||
'serial_number': repair.serial_number or '',
|
||||
'warehouse_location': repair.customer_location or '',
|
||||
@ -127,7 +135,17 @@ class OutboundService:
|
||||
return None
|
||||
|
||||
@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_spec = ""
|
||||
base_cat = ""
|
||||
@ -154,7 +172,10 @@ class OutboundService:
|
||||
base_name = item.base.name
|
||||
|
||||
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 {
|
||||
'id': item.id,
|
||||
@ -165,7 +186,11 @@ class OutboundService:
|
||||
'material_type': base_type or "",
|
||||
'source_table': table_name,
|
||||
'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', ''),
|
||||
'warehouse_location': getattr(item, 'warehouse_location', ''),
|
||||
'barcode': getattr(item, 'barcode', '')
|
||||
|
||||
Reference in New Issue
Block a user