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:
@ -54,6 +54,63 @@ def filter_item_by_permissions(item_dict, user_permissions):
|
||||
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. 扫码查询库存接口 (关联三个库存表)
|
||||
# GET /api/v1/outbound/scan?barcode=...
|
||||
@ -66,13 +123,20 @@ def scan_barcode():
|
||||
if not barcode:
|
||||
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:
|
||||
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
|
||||
result = OutboundService.get_stock_by_barcode(barcode)
|
||||
result = OutboundService.get_stock_by_barcode(barcode, reserved_map)
|
||||
|
||||
if result:
|
||||
# ★ Fail-Closed: 扫码响应剥离价格字段
|
||||
result.pop('price', None)
|
||||
# 预占是否生效:false 表示已降级为实时可用量(前端可据此提示)
|
||||
result['reservation_applied'] = bool(reserved_map)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '扫描成功',
|
||||
@ -368,11 +432,22 @@ def _allocate_bom_requirements(requirements, company_limit,
|
||||
@jwt_required()
|
||||
def get_stock_alternatives():
|
||||
"""
|
||||
查询某物料的全部可替代库位(available_quantity > 0)。
|
||||
查询某物料的全部可替代库位。
|
||||
|
||||
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,
|
||||
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:
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
@ -385,11 +460,22 @@ def get_stock_alternatives():
|
||||
except (TypeError, ValueError):
|
||||
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.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
company_limit = get_current_company_filter()
|
||||
@ -400,10 +486,17 @@ def get_stock_alternatives():
|
||||
(StockSemi, 'stock_semi', '半成品'),
|
||||
(StockProduct, 'stock_product', '成品'),
|
||||
):
|
||||
q = model.query.filter(
|
||||
model.base_id == base_id,
|
||||
model.available_quantity > 0, # ★ 只给真正能拿的
|
||||
)
|
||||
# ★ 只给真正能拿的;但本单自己预占的行即使实时可用量为 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:
|
||||
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
|
||||
try:
|
||||
@ -415,6 +508,8 @@ def get_stock_alternatives():
|
||||
continue
|
||||
|
||||
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({
|
||||
'stock_id': s.id,
|
||||
'source_table': source_table,
|
||||
@ -422,13 +517,18 @@ def get_stock_alternatives():
|
||||
'sku': s.sku or '',
|
||||
'batch_number': getattr(s, 'batch_number', '') or getattr(s, 'serial_number', '') 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
|
||||
and source_table == prefer_table),
|
||||
})
|
||||
|
||||
# 排序:推荐行置顶,其余按可用量降序(工人优先看到货最多的库位)
|
||||
# 排序:推荐行置顶,其余按有效可用量降序(工人优先看到货最多的库位)
|
||||
items.sort(key=lambda x: (not x['is_locked'], -x['available_quantity']))
|
||||
|
||||
return jsonify({
|
||||
@ -436,6 +536,7 @@ def get_stock_alternatives():
|
||||
'data': {
|
||||
'items': items,
|
||||
'total_available': round(sum(i['available_quantity'] for i in items), 4),
|
||||
'reservation_applied': bool(reserved_map),
|
||||
}
|
||||
}), 200
|
||||
|
||||
|
||||
Reference in New Issue
Block a user