Compare commits
5 Commits
b8561f69f7
...
c6694043d4
| Author | SHA1 | Date | |
|---|---|---|---|
| c6694043d4 | |||
| 06cb5f1cac | |||
| b313fefdfa | |||
| a8a3c82331 | |||
| cbcedecba2 |
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -101,14 +101,37 @@ class TransService:
|
||||
# · 借库转报废(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 = [
|
||||
{'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 = {}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user