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)
return jsonify({"code": 200, "msg": "更新成功"})
except ValueError as ve:
# 业务校验失败(如下调数量会击穿可用库存)→ 400非服务端故障
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

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

View File

@ -238,6 +238,9 @@ def update_semi(id):
data.pop(field, None)
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except ValueError as ve:
# 业务校验失败(如下调数量会击穿可用库存)→ 400非服务端故障
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e:
traceback.print_exc()
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
# ==============================================================================
# 辅助函数:本单预占索引(扫码阶段回加「本单自己锁掉的货」)
# ==============================================================================
# 扫码页面上工人应当能以「实时可用量 + 本单预占量」为上限 —— 本单自己锁定
# 的货当然扫得进去。若直接用实时 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

View File

@ -327,6 +327,20 @@ class BuyInboundService:
if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity)
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.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff

View File

@ -289,6 +289,17 @@ class ProductInboundService:
if 'in_quantity' in data:
new_qty = float(data['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.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff

View File

@ -367,6 +367,17 @@ class SemiInboundService:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
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.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff

View File

@ -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)
# =============================================================================
# 执行阶段:身份校验
# =============================================================================
@ -440,7 +494,22 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
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 []:
st = norm_text(s.get('source_table'))
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.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):

View File

@ -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', '')
@ -233,11 +258,22 @@ class OutboundService:
verify_scanned, restore_then_deduct,
)
_approved = approval.get_items()
# 仅处理库存类来源维修单trans_repair不走库存预占
_scanned = [i for i in items if i.get('source_table') != 'trans_repair']
if _scanned:
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
# ★ Fail-Closed只认库存类来源。只要一条库存物料都没扫到就整单失败。
#
# 旧代码写作 `_scanned = [... if source_table != 'trans_repair']`
# 再 `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:
for item in items:

View File

@ -101,14 +101,37 @@ class TransService:
# · 借库转报废scrap_borrow在确认损失时扣 stock其注释明确假设
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
# ==============================================================
# ★ 防线 2.5Fail-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 = [
{'source_table': i.get('source_table'), 'stock_id': i.get('id'),
'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)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
# 累计本次扫码出库量用于下方防线4的二次校验
dispatch_acc = {}

View File

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

View File

@ -31,6 +31,9 @@ export interface OutboundSubmitData {
remark?: string
}
/** 单据类型出库单与借库单是两张独立表、ID 空间独立,必须显式区分 */
export type ScanBizType = 'outbound' | 'borrow'
export interface ScanResult {
id: number
sku: string
@ -38,7 +41,14 @@ export interface ScanResult {
spec_model: string
source_table: string // 'stock_buy' | 'stock_product' ...
stock_quantity: number
/** ★ 有效可用量 = 实时可用量 + 本单在该行的预占量(本单可扫的上限) */
available_quantity: number
/** 实时可用量原值(不含本单预占),备查/展示用 */
raw_available_quantity?: number
/** 本单在该库存行上的预占量 */
reserved_quantity?: number
/** 预占是否已生效false 表示后端降级为实时可用量 */
reservation_applied?: boolean
batch_number?: string
warehouse_location?: string
barcode?: string
@ -47,13 +57,24 @@ export interface ScanResult {
/**
* 根据条码获取库存物品详情
*
* ★ 传 requestId + bizType 后,返回的 available_quantity 会回加本单在该行的
* 预占量。出库选单提交申请时已扣减 available_quantity若扫码时不回加
* 被本单占满的行会显示 0工人根本扫不进去。
*
* @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>({
url: '/v1/outbound/scan',
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 locked 本单锁定的批次(用于标注「推荐」),可选
* @param requestId 本单 ID出库审批单 / 借库审批单),可选
* @param bizType 单据类型,决定 requestId 查哪张表(默认 outbound
*/
export function getStockAlternatives(
baseId: number,
locked?: { stock_id?: number; source_table?: string }
locked?: { stock_id?: number; source_table?: string },
requestId?: number | null,
bizType: ScanBizType = 'outbound'
) {
return request({
url: '/v1/outbound/alternatives',
@ -191,7 +219,9 @@ export function getStockAlternatives(
params: {
base_id: baseId,
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 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>({
url: '/v1/outbound/scan',
method: 'get',
params: { barcode }
params: { barcode, request_id: requestId, biz_type: bizType }
})
}

View File

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

View File

@ -469,10 +469,13 @@ const loadAlternatives = async (row: any) => {
altItems.value = []
altTotal.value = 0
try {
const res: any = await getStockAlternatives(row.base_id, {
stock_id: row.stock_id,
source_table: row.source_table,
})
// ★ 带上本单 id本单锁定的批次实时可用量为 0不回加的话会从列表里消失
const res: any = await getStockAlternatives(
row.base_id,
{ stock_id: row.stock_id, source_table: row.source_table },
selectedRequest.value?.id,
'outbound'
)
altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0
} catch (e) {
@ -674,7 +677,7 @@ const restoreDraft = async (requestId: number) => {
// ★ 刷新库存:草稿里的 available_quantity 是**扫描那一刻**的快照,
// 若期间别人出库了,界面会显示陈旧数字,工人扫满后到提交时才被
// 后端拒绝(白扫一场)。这里重新拉一次实时可用量。
await refreshStockFromDraft()
await refreshStockFromDraft(requestId)
} catch (e) {
console.warn('恢复草稿失败', e)
}
@ -684,20 +687,28 @@ const restoreDraft = async (requestId: number) => {
* 用实时库存刷新购物车行的「库存」列。
*
* 草稿里的 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 baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
// ★ 显式透传单据 id而非依赖 selectedRequest 已解析),保证预占回加一定生效
const rid = requestId ?? selectedRequest.value?.id
try {
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>()
for (const r of results) {
@ -874,7 +885,9 @@ const handleManualInput = async () => {
}
// 2. 调用 API 查询
const res = await getStockByBarcode(code)
// ★ 带上本单 id申请阶段已把本单要的货从 available_quantity 扣掉(预占),
// 不回加的话,被本单占满的行会显示 0工人根本扫不进去。
const res = await getStockByBarcode(code, selectedRequest.value?.id, 'outbound')
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)

View File

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