5 Commits

Author SHA1 Message Date
c6694043d4 V3.78,9.11推送 2026-09-11 10:35:34 +08:00
06cb5f1cac fix(reservation): 实扫来源不可识别时整单失败,避免预占永久锁死
出库与借库两条执行路径原先写作:

    _scanned = [i for i in items if i.get('source_table') != 'trans_repair']
    if _scanned:
        verify_scanned(...); restore_then_deduct(...)

`if _scanned:` 为假时校验与释放被整体跳过,而下方仍无条件把单据置为
status=3(已完成)。此时申请阶段 reserve_for_items() 扣掉的 available_quantity
无人归还,且 status=3 之后 /close(要求 status==1)与 /withdraw(要求
status∈(0,1))都拒绝再释放 —— 预占就此永久锁死,成为谁也领不走的幽灵库存,
且没有流水可供追溯。

改为 Fail-Closed:实扫明细的来源必须全部落在 model_map 内,否则整单失败。
事务回滚后单据保持 status=1、预占原样保留,库管可重试执行,或走驳回/撤回 ——
任一路径都能正常归还预占。

- outbound_service.create_outbound_batch:以 model_map 白名单过滤,
  并要求 _scanned 非空
- trans_service.execute_dispatch:先显式校验来源合法性并列出非法值,
  再要求 _scanned 非空
2026-09-11 10:34:55 +08:00
b313fefdfa fix(web): 扫码页与备选库位带单据ID,按有效可用量限流
配合后端预占回加:扫码/借库/审批页面调用 /outbound/scan 与 /outbound/alternatives
时带上本单 id 与 biz_type,拿到的 available_quantity 即为「实时可用量 + 本单
预占」,本单锁定的行不会再因实时可用量为 0 而被拦住或从列表消失。

后端已在响应边界完成归一化,故前端模板与校验逻辑(:max、库存列展示、超量
警告、草稿存取)一律不动,只多传参数 —— 避免把同一语义散落到多个读写点。

- api/outbound.ts:getStockByBarcode / getStockAlternatives 加 requestId 与
  bizType 参数,导出 ScanBizType,ScanResult 补 reserved_quantity 等字段
- api/transaction.ts:同名的历史副本同步签名(当前无调用方,加注释指向唯一来源)
- views/outbound/create.vue、views/transaction/borrow.vue:各 3 处调用点带上
  单据 id;refreshStockFromDraft 改为显式透传 id,不依赖 computed 已解析
- views/borrow/approval/index.vue:审批页也带上本单 id,否则待审批单
  (预占已生效)锁定的行会因实时可用量为 0 而从备选列表消失

借库侧 request_id 是 BorrowApproval.id,靠 biz_type='borrow' 与出库单区分 ——
两张表 ID 空间独立,不可省。
2026-09-11 10:34:23 +08:00
a8a3c82331 fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负
available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处
缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。

1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct)

   物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行
   穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量
   5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都
   跳过,是裸扣。

   已实测复现:借库与出库路径均可把单行扣成 -3。

   校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖):
   释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证
   必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。

2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound)

   按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被
   预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。
   正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。

配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支
(同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成
服务端故障。

验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行
本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、
真实借库单 52 行全额执行正常、全库无负可用数。
2026-09-11 10:34:15 +08:00
cbcedecba2 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。
2026-09-11 10:34:00 +08:00
16 changed files with 417 additions and 60 deletions

View File

@ -225,6 +225,9 @@ def update_buy(id):
BuyInboundService.update_inbound(id, data) BuyInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except ValueError as ve:
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -227,6 +227,9 @@ def update(id):
data.pop(field, None) data.pop(field, None)
ProductInboundService.update_inbound(id, data) ProductInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except ValueError as ve:
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -238,6 +238,9 @@ def update_semi(id):
data.pop(field, None) data.pop(field, None)
SemiInboundService.update_inbound(id, data) SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except ValueError as ve:
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

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

@ -327,6 +327,20 @@ class BuyInboundService:
if 'in_quantity' in data: if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity) diff = float(data['in_quantity']) - float(stock.in_quantity)
if diff != 0: if diff != 0:
# ★ 下调数量前校验可用数下限。
# 该批次可能已有部分被预占/出库/借出,硬扣会让
# available_quantity 变成负数 —— 而它一旦为负,
# 预占释放、行级防穿仓校验、盘点平账的全部算术都会失真。
# 注意:stock >= available 恒成立(正常数据下),
# 故守住 available 同时也就守住了 stock 不会为负。
if diff < 0:
avail_now = float(stock.available_quantity or 0)
if -diff > avail_now:
raise ValueError(
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
f"请先处理相关单据或改为调整盘点差异。"
)
stock.in_quantity = float(data['in_quantity']) stock.in_quantity = float(data['in_quantity'])
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff

View File

@ -289,6 +289,17 @@ class ProductInboundService:
if 'in_quantity' in data: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity) diff = new_qty - float(stock.in_quantity)
# ★ 下调数量前校验可用数下限(同 buy_service):
# 该批次可能已有部分被预占/出库/借出,硬扣会让 available
# 变成负数,破坏预占释放与盘点的算术。
if diff < 0:
avail_now = float(stock.available_quantity or 0)
if -diff > avail_now:
raise ValueError(
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
f"请先处理相关单据或改为调整盘点差异。"
)
stock.in_quantity = new_qty stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff

View File

@ -367,6 +367,17 @@ class SemiInboundService:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity) diff = new_qty - float(stock.in_quantity)
if diff != 0: if diff != 0:
# ★ 下调数量前校验可用数下限(同 buy_service):
# 该批次可能已有部分被预占/出库/借出,硬扣会让 available
# 变成负数,破坏预占释放与盘点的算术。
if diff < 0:
avail_now = float(stock.available_quantity or 0)
if -diff > avail_now:
raise ValueError(
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
f"请先处理相关单据或改为调整盘点差异。"
)
stock.in_quantity = new_qty stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff

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)
# ============================================================================= # =============================================================================
# 执行阶段:身份校验 # 执行阶段:身份校验
# ============================================================================= # =============================================================================
@ -440,7 +494,22 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
f"(需 {need},实剩 {have}),无法出库" f"(需 {need},实剩 {have}),无法出库"
) )
# 3. 逐行扣减(行级校验实物库存,避免把某批次扣成负数) # 3. 逐行扣减(行级校验,避免把某批次扣成负数)
#
# ★ 为什么这里必须**逐行**校验可用量,而上方还要保留物料级校验:
# 物料级校验(第 2 步)只保证「Σ实扫 ≤ Σ可用」这一总量关系,
# 拦不住「总量守恒但单行穿仓」——例如同物料下 A 批可用 2、B 批可用 8,
# 工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。
# available_quantity 一旦为负,预占/释放/盘点的全部算术都失去意义。
#
# ★ 为什么放在 release_reserved() **之后**才不会误拒合法换批次:
# 释放后每行 available = 实时可用量 + 本单在该行的预占量。工人扫自己
# 预占过的批次时 raw >= 0 保证 available >= 本单分配量,必然放行;
# 换批次时各批次的实时可用量就是它自己的上限。下方注释 2 中「A 批预占 5、
# 改扫 B 批 2 + C 批 3」的例子,只要 B、C 各有 2、3 件可用,同样通过。
#
# 由此确立不变量:available_quantity >= 0 在扣减后恒成立,
# 不再依赖前端 :max 的约束(前端可被绕过,陈旧草稿也不会夹取数量)。
for s in scanned_items or []: for s in scanned_items or []:
st = norm_text(s.get('source_table')) st = norm_text(s.get('source_table'))
sid = s.get('stock_id') sid = s.get('stock_id')
@ -467,7 +536,15 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
) )
row.stock_quantity = stock - qty row.stock_quantity = stock - qty
row.available_quantity = float(row.available_quantity or 0) - qty # ★ 行级下限:不要把该批次扣成负可用量
avail = float(row.available_quantity or 0)
if qty > avail:
raise ValueError(
f"物料【{identity_label(stock_identity(row))}】该批次可用不足"
f"(需 {qty},实剩 {avail}),无法{'出库' if deduct_stock else '借出'}。"
f"请按实际库存拆分扫码。"
)
row.available_quantity = avail - qty
def _sum_available_for_identity(key): def _sum_available_for_identity(key):

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', '')
@ -233,11 +258,22 @@ class OutboundService:
verify_scanned, restore_then_deduct, verify_scanned, restore_then_deduct,
) )
_approved = approval.get_items() _approved = approval.get_items()
# 仅处理库存类来源;维修单(trans_repair)不走库存预占
_scanned = [i for i in items if i.get('source_table') != 'trans_repair'] # ★ Fail-Closed:只认库存类来源。只要一条库存物料都没扫到就整单失败。
if _scanned: #
verify_scanned(_scanned, _approved) # 旧代码写作 `_scanned = [... if source_table != 'trans_repair']`
restore_then_deduct(_scanned, _approved) # 再 `if _scanned:` —— 一旦为假,校验与释放被整体跳过,而下方仍
# 无条件执行 approval.status = 3。此时申请阶段 reserve_for_items()
# 扣掉的 available_quantity 无人归还,且 status=3 没有任何释放入口
# (/close 要求 status==1、/withdraw 要求 status∈(0,1)),预占即
# 永久锁死 —— 幽灵库存。
# 现在直接要求 _scanned 非空:维修单等非库存来源一并被挡在门外。
_scanned = [i for i in items if i.get('source_table') in model_map]
if not _scanned:
raise ValueError("未扫描到有效的库存物料,出库失败,请检查扫码内容")
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
try: try:
for item in items: for item in items:

View File

@ -101,14 +101,37 @@ class TransService:
# · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设 # · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。 # 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
# ============================================================== # ==============================================================
# ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
#
# 原先此处写作 `if _scanned_for_check:` —— 不在 model_map 的明细会被
# 静默过滤。若整批明细都不可识别,校验与释放被整体跳过,下方主循环
# 也逐条 `continue`,末尾却照常把 approval.status 置为 3,于是:
# · 申请阶段预占的 available_quantity 无人释放 → 幽灵库存永久锁死;
# · 单据已非 status=1,/close 与 /withdraw 都拒绝再释放;
# · 且没有任何 TransBorrow 流水可供追溯。
# 现在改为整单失败:事务回滚,单据保持 status=1、预占原样保留,
# 库管可重试执行,或走驳回/撤回 —— 任一路径都能正常归还预占。
# ==============================================================
unknown_source = sorted({
str(i.get('source_table')) for i in items
if not isinstance(i.get('source_table'), str)
or i.get('source_table') not in model_map
})
if unknown_source:
raise ValueError(
f"实扫明细的库存来源不合法:{'、'.join(unknown_source)},"
f"仅支持 {'、'.join(sorted(model_map))}"
)
# 经上方校验后 source_table 必然合法,故本列表与 items 一一对应,
# 不存在「被过滤掉的明细」。空列表由 verify_scanned 兜底报错。
_scanned_for_check = [ _scanned_for_check = [
{'source_table': i.get('source_table'), 'stock_id': i.get('id'), {'source_table': i.get('source_table'), 'stock_id': i.get('id'),
'quantity': i.get('out_quantity')} 'quantity': i.get('out_quantity')}
for i in items if i.get('source_table') in model_map for i in items
] ]
if _scanned_for_check: verify_scanned(_scanned_for_check, approved_items)
verify_scanned(_scanned_for_check, approved_items) restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
# 累计本次扫码出库量(用于下方防线4的二次校验) # 累计本次扫码出库量(用于下方防线4的二次校验)
dispatch_acc = {} dispatch_acc = {}

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.77 当前版本:V3.78
</span> </span>
</footer> </footer>

View File

@ -31,6 +31,9 @@ export interface OutboundSubmitData {
remark?: string remark?: string
} }
/** 单据类型:出库单与借库单是两张独立表、ID 空间独立,必须显式区分 */
export type ScanBizType = 'outbound' | 'borrow'
export interface ScanResult { export interface ScanResult {
id: number id: number
sku: string sku: string
@ -38,7 +41,14 @@ export interface ScanResult {
spec_model: string spec_model: string
source_table: string // 'stock_buy' | 'stock_product' ... source_table: string // 'stock_buy' | 'stock_product' ...
stock_quantity: number stock_quantity: number
/** ★ 有效可用量 = 实时可用量 + 本单在该行的预占量(本单可扫的上限) */
available_quantity: number available_quantity: number
/** 实时可用量原值(不含本单预占),备查/展示用 */
raw_available_quantity?: number
/** 本单在该库存行上的预占量 */
reserved_quantity?: number
/** 预占是否已生效;false 表示后端降级为实时可用量 */
reservation_applied?: boolean
batch_number?: string batch_number?: string
warehouse_location?: string warehouse_location?: string
barcode?: string barcode?: string
@ -47,13 +57,24 @@ export interface ScanResult {
/** /**
* 根据条码获取库存物品详情 * 根据条码获取库存物品详情
*
* ★ 传 requestId + bizType 后,返回的 available_quantity 会回加本单在该行的
* 预占量。出库选单提交申请时已扣减 available_quantity,若扫码时不回加,
* 被本单占满的行会显示 0,工人根本扫不进去。
*
* @param barcode 扫描到的条码 * @param barcode 扫描到的条码
* @param requestId 本单 ID(出库审批单 / 借库审批单)
* @param bizType 单据类型,决定 requestId 查哪张表(默认 outbound)
*/ */
export function getStockByBarcode(barcode: string) { export function getStockByBarcode(
barcode: string,
requestId?: number | null,
bizType: ScanBizType = 'outbound'
) {
return request<any, ScanResult>({ return request<any, ScanResult>({
url: '/v1/outbound/scan', url: '/v1/outbound/scan',
method: 'get', method: 'get',
params: { barcode } params: { barcode, request_id: requestId, biz_type: bizType }
}) })
} }
@ -175,15 +196,22 @@ export function bomMatchStock(payload: BomRequirement[] | { requirements: BomReq
* 备选库位查询 —— 为「物理覆盖」提供可见性 * 备选库位查询 —— 为「物理覆盖」提供可见性
* *
* 申请单把货预占在某个库位后,工人现场可能进不去该库位,需要改扫同物料的 * 申请单把货预占在某个库位后,工人现场可能进不去该库位,需要改扫同物料的
* 其它批次。本接口按 available_quantity > 0 返回**真正能拿**的库位, * 其它批次。本接口返回**真正能拿**的库位(实时可用量 > 0,或本单已预占该行),
* 并标注哪一条是本单锁定的推荐行。 * 并标注哪一条是本单锁定的推荐行。
* *
* ★ 传 requestId + bizType 后,available_quantity 会回加本单在该行的预占量;
* 否则被本单占满的行实时可用量为 0,会从列表里凭空消失。
*
* @param baseId 物料 ID(申请明细里的 base_id) * @param baseId 物料 ID(申请明细里的 base_id)
* @param locked 本单锁定的批次(用于标注「推荐」),可选 * @param locked 本单锁定的批次(用于标注「推荐」),可选
* @param requestId 本单 ID(出库审批单 / 借库审批单),可选
* @param bizType 单据类型,决定 requestId 查哪张表(默认 outbound)
*/ */
export function getStockAlternatives( export function getStockAlternatives(
baseId: number, baseId: number,
locked?: { stock_id?: number; source_table?: string } locked?: { stock_id?: number; source_table?: string },
requestId?: number | null,
bizType: ScanBizType = 'outbound'
) { ) {
return request({ return request({
url: '/v1/outbound/alternatives', url: '/v1/outbound/alternatives',
@ -191,7 +219,9 @@ export function getStockAlternatives(
params: { params: {
base_id: baseId, base_id: baseId,
stock_id: locked?.stock_id, stock_id: locked?.stock_id,
source_table: locked?.source_table source_table: locked?.source_table,
request_id: requestId,
biz_type: bizType
} }
}) })
} }

View File

@ -47,13 +47,24 @@ export interface ScanResult {
/** /**
* 根据条码获取库存物品详情 * 根据条码获取库存物品详情
*
* ⚠ 本文件是与 `@/api/outbound` 同名的**历史副本**(同 URL、同返回形状),
* 当前无任何调用方 —— 借库扫码页 `views/transaction/borrow.vue` 是从
* `@/api/outbound` 导入的。改动请落到那边,这里同步仅为避免下次改漏。
*
* @param barcode 扫描到的条码 * @param barcode 扫描到的条码
* @param requestId 本单 ID(出库审批单 / 借库审批单)
* @param bizType 单据类型,决定 requestId 查哪张表(默认 outbound)
*/ */
export function getStockByBarcode(barcode: string) { export function getStockByBarcode(
barcode: string,
requestId?: number | null,
bizType: 'outbound' | 'borrow' = 'outbound'
) {
return request<any, ScanResult>({ return request<any, ScanResult>({
url: '/v1/outbound/scan', url: '/v1/outbound/scan',
method: 'get', method: 'get',
params: { barcode } params: { barcode, request_id: requestId, biz_type: bizType }
}) })
} }

View File

@ -62,7 +62,7 @@
placement="right" placement="right"
:width="340" :width="340"
trigger="click" trigger="click"
@show="loadAlternatives(item)" @show="loadAlternatives(item, row.id)"
> >
<!-- ★ 整个库位格子都是点击热区,工人不必精准点图标 --> <!-- ★ 整个库位格子都是点击热区,工人不必精准点图标 -->
<template #reference> <template #reference>
@ -310,16 +310,22 @@ const altLoading = ref(false)
const altItems = ref<any[]>([]) const altItems = ref<any[]>([])
const altTotal = ref(0) const altTotal = ref(0)
const loadAlternatives = async (item: any) => { const loadAlternatives = async (item: any, approvalId?: number) => {
if (!item?.base_id) return if (!item?.base_id) return
altLoading.value = true altLoading.value = true
altItems.value = [] altItems.value = []
altTotal.value = 0 altTotal.value = 0
try { try {
const res: any = await getStockAlternatives(item.base_id, { // ★ 带上本单 id 且 biz_type='borrow':
stock_id: item.stock_id, // 审批阶段看到的多是 status=0(待审批)的单据,而预占在**提交申请时**
source_table: item.source_table, // 就已冻结可用数。不回加的话,被本单占满的行实时可用量为 0,
}) // 恰好会从列表里消失 —— 审批人反而看不到本单锁定的批次。
const res: any = await getStockAlternatives(
item.base_id,
{ stock_id: item.stock_id, source_table: item.source_table },
approvalId,
'borrow'
)
altItems.value = res?.data?.items || [] altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0 altTotal.value = res?.data?.total_available || 0
} catch (e) { } catch (e) {

View File

@ -469,10 +469,13 @@ const loadAlternatives = async (row: any) => {
altItems.value = [] altItems.value = []
altTotal.value = 0 altTotal.value = 0
try { try {
const res: any = await getStockAlternatives(row.base_id, { // ★ 带上本单 id:本单锁定的批次实时可用量为 0,不回加的话会从列表里消失
stock_id: row.stock_id, const res: any = await getStockAlternatives(
source_table: row.source_table, row.base_id,
}) { stock_id: row.stock_id, source_table: row.source_table },
selectedRequest.value?.id,
'outbound'
)
altItems.value = res?.data?.items || [] altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0 altTotal.value = res?.data?.total_available || 0
} catch (e) { } catch (e) {
@ -674,7 +677,7 @@ const restoreDraft = async (requestId: number) => {
// ★ 刷新库存:草稿里的 available_quantity 是**扫描那一刻**的快照, // ★ 刷新库存:草稿里的 available_quantity 是**扫描那一刻**的快照,
// 若期间别人出库了,界面会显示陈旧数字,工人扫满后到提交时才被 // 若期间别人出库了,界面会显示陈旧数字,工人扫满后到提交时才被
// 后端拒绝(白扫一场)。这里重新拉一次实时可用量。 // 后端拒绝(白扫一场)。这里重新拉一次实时可用量。
await refreshStockFromDraft() await refreshStockFromDraft(requestId)
} catch (e) { } catch (e) {
console.warn('恢复草稿失败', e) console.warn('恢复草稿失败', e)
} }
@ -684,20 +687,28 @@ const restoreDraft = async (requestId: number) => {
* 用实时库存刷新购物车行的「库存」列。 * 用实时库存刷新购物车行的「库存」列。
* *
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期 * 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时 * (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时值,
* 可用量,避免工人基于陈旧数字扫满、到提交时才发现不够。 * 避免工人基于陈旧数字扫满、到提交时才发现不够。
* *
* 复用 /alternatives 端点 —— 它本来就按 base_id 返回各库存行的实时可用量。 * 复用 /alternatives 端点 —— 它按 base_id 返回各库存行的实时可用量。
* ★ 传入本单 id 后,返回的 available_quantity 是**有效可用量**
* (实时值 + 本单预占),故本单锁定行不会因实时值为 0 而被过滤掉,
* 下面的「已扫数量 > 最新可用量」判定也不会误报。
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。 * 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
*/ */
const refreshStockFromDraft = async () => { const refreshStockFromDraft = async (requestId?: number | null) => {
const plan = selectedRequest.value?.items || [] const plan = selectedRequest.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))] const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return if (!baseIds.length) return
// ★ 显式透传单据 id(而非依赖 selectedRequest 已解析),保证预占回加一定生效
const rid = requestId ?? selectedRequest.value?.id
try { try {
const results = await Promise.all( const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null)) (baseIds as number[]).map(bid =>
getStockAlternatives(bid, undefined, rid, 'outbound').catch(() => null)
)
) )
const latestByKey = new Map<string, number>() const latestByKey = new Map<string, number>()
for (const r of results) { for (const r of results) {
@ -874,7 +885,9 @@ const handleManualInput = async () => {
} }
// 2. 调用 API 查询 // 2. 调用 API 查询
const res = await getStockByBarcode(code) // ★ 带上本单 id:申请阶段已把本单要的货从 available_quantity 扣掉(预占),
// 不回加的话,被本单占满的行会显示 0,工人根本扫不进去。
const res = await getStockByBarcode(code, selectedRequest.value?.id, 'outbound')
if (res.data) { if (res.data) {
const item = res.data const item = res.data
const availQty = parseFloat(item.available_quantity || 0) const availQty = parseFloat(item.available_quantity || 0)

View File

@ -424,10 +424,13 @@ const loadAlternatives = async (row: any) => {
altItems.value = [] altItems.value = []
altTotal.value = 0 altTotal.value = 0
try { try {
const res: any = await getStockAlternatives(row.base_id, { // ★ 带上本单 id:本单锁定的批次实时可用量为 0,不回加的话会从列表里消失
stock_id: row.stock_id, const res: any = await getStockAlternatives(
source_table: row.source_table, row.base_id,
}) { stock_id: row.stock_id, source_table: row.source_table },
selectedApprovalId.value,
'borrow'
)
altItems.value = res?.data?.items || [] altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0 altTotal.value = res?.data?.total_available || 0
} catch (e) { } catch (e) {
@ -598,7 +601,7 @@ const restoreDraft = async (requestId: number) => {
// ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照, // ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照,
// 期间别人出库/借出会消耗可用量,需重新拉取实时值。 // 期间别人出库/借出会消耗可用量,需重新拉取实时值。
await refreshStockFromDraft() await refreshStockFromDraft(requestId)
} catch (e) { } catch (e) {
console.warn('恢复草稿失败', e) console.warn('恢复草稿失败', e)
} }
@ -607,15 +610,24 @@ const restoreDraft = async (requestId: number) => {
/** /**
* 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。 * 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。
* 复用 /alternatives 端点;失败不阻断,沿用草稿快照。 * 复用 /alternatives 端点;失败不阻断,沿用草稿快照。
*
* ★ 传入本单 id 后,返回的 available_quantity 是**有效可用量**
* (实时值 + 本单预占)。借库申请提交时已冻结可用数,不回加的话
* 被本单占满的行会显示 0,下面「已借数量 > 最新可用量」也会误报。
*/ */
const refreshStockFromDraft = async () => { const refreshStockFromDraft = async (requestId?: number | null) => {
const plan = selectedApproval.value?.items || [] const plan = selectedApproval.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))] const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return if (!baseIds.length) return
// ★ 显式透传单据 id(而非依赖 selectedApproval 已解析)
const rid = requestId ?? selectedApproval.value?.id ?? selectedApprovalId.value
try { try {
const results = await Promise.all( const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null)) (baseIds as number[]).map(bid =>
getStockAlternatives(bid, undefined, rid, 'borrow').catch(() => null)
)
) )
const latestByKey = new Map<string, number>() const latestByKey = new Map<string, number>()
for (const r of results) { for (const r of results) {
@ -793,7 +805,10 @@ const handleManualInput = async () => {
} }
// 查库 // 查库
const res = await getStockByBarcode(code) // ★ 带上本单 id:借库申请提交时已冻结可用数(available_quantity 被扣减),
// 不回加的话,被本单占满的行会显示 0,工人根本扫不进去。
// biz_type='borrow' 不可省 —— 出库单与借库单是两张独立表、ID 空间独立。
const res = await getStockByBarcode(code, selectedApprovalId.value, 'borrow')
if (res.data) { if (res.data) {
const item = res.data const item = res.data
const availQty = parseFloat(item.available_quantity || 0) const availQty = parseFloat(item.available_quantity || 0)