diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 3536361..184a956 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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 diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 1d6ae82..9cad24a 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -257,6 +257,60 @@ def release_reserved(items): 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) + + # ============================================================================= # 执行阶段:身份校验 # ============================================================================= diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 0d2bbf5..a65831c 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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', '')