feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷
问题现象
--------
BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件,
并提示库存不足。
根因
----
两个接口口径不一致:
· GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839
· POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1
前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行,
若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。
为何必须放在后端
----------------
前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖;
且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。
更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图,
而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减)
使用同一份数据源。
改动
----
bom-match-stock 新增分配模式:
请求 { requirements: [{base_id, required_qty, name, spec_model}] }
响应 { items: [...已分配行], shortages: [...缺料明细] }
_allocate_bom_requirements() 在 DB 层完成:
1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行
(带公司隔离,join base 取名称规格);
2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数;
3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty;
4. 分配不足记录 shortage 但不阻断其它物料。
每行仍携带 uniqueKey,前端可直接入购物车。
返回前剥离价格成本字段(Fail-Closed)。
旧查询模式(child_ids)保留,兼容未改造的调用方。
顺带修复一处静默失败
--------------------
查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。
改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。
实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺;
base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5;
需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
This commit is contained in:
@ -209,27 +209,199 @@ def get_outbound_list():
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
def _allocate_bom_requirements(requirements, company_limit,
|
||||
StockBuy, StockSemi, StockProduct, MaterialBase):
|
||||
"""
|
||||
★ BOM 需求分配核心
|
||||
|
||||
对每个 base_id:
|
||||
1. 直接查库取该物料的**全部可用库存行**(available_quantity > 0);
|
||||
2. 按库位优先、库存量降序排序(大行优先,减少拆分行数);
|
||||
3. 依次扣减 required_qty,为每一行产出 (stock_id, source_table, allocated_qty);
|
||||
4. 分配不足时记录缺口,供前端提示,但不阻断其它物料的分配。
|
||||
|
||||
返回的每一行都携带真实 stock_id 与 source_table,可直接入购物车;
|
||||
因为这些数字直接来自 DB,前端无需(也不应)再做任何分配运算。
|
||||
|
||||
并发说明:此处只读取快照用于装配购物车,真正扣减在提交出库时由
|
||||
create_outbound_batch 以 with_for_update 加锁并二次校验可用量。
|
||||
"""
|
||||
from flask import jsonify
|
||||
from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见
|
||||
|
||||
# 归一化需求,容忍字符串数字
|
||||
reqs = []
|
||||
for r in requirements:
|
||||
try:
|
||||
bid = int(r.get('base_id'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
need = float(r.get('required_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
need = 0.0
|
||||
if bid <= 0 or need <= 0:
|
||||
continue
|
||||
reqs.append({'base_id': bid, 'required_qty': need,
|
||||
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''})
|
||||
|
||||
if not reqs:
|
||||
return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400
|
||||
|
||||
base_ids = list({r['base_id'] for r in reqs})
|
||||
|
||||
# ---- 一次性拉取全部候选库存行(三表)----
|
||||
# 按 (base_id, source_table) 归集
|
||||
rows_by_base = {}
|
||||
|
||||
for model, source_table, type_label, type_key in (
|
||||
(StockBuy, 'stock_buy', '采购件', 'material'),
|
||||
(StockSemi, 'stock_semi', '半成品', 'semi'),
|
||||
(StockProduct, 'stock_product', '成品', 'product'),
|
||||
):
|
||||
try:
|
||||
q = model.query.filter(
|
||||
model.base_id.in_(base_ids),
|
||||
model.available_quantity > 0, # ★ 只取真正可用的行
|
||||
)
|
||||
if company_limit is not None:
|
||||
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
|
||||
rows = q.options(joinedload(model.base)).all()
|
||||
except Exception as e:
|
||||
# 不静默:某张表查询失败会直接表现为"该物料无库存",极难排查
|
||||
current_app.logger.error(
|
||||
f"[bom-allocate] {source_table} 查询失败: {type(e).__name__}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
for s in rows:
|
||||
bid = int(s.base_id)
|
||||
rows_by_base.setdefault(bid, []).append(
|
||||
(float(s.available_quantity or 0), source_table, type_key, type_label, s)
|
||||
)
|
||||
|
||||
# ---- 逐物料分配 ----
|
||||
allocated_items = []
|
||||
shortages = []
|
||||
|
||||
for req in reqs:
|
||||
bid = req['base_id']
|
||||
remaining = req['required_qty']
|
||||
|
||||
# 可用量降序:优先进大行,减少购物车拆分行数
|
||||
candidates = sorted(rows_by_base.get(bid, []), key=lambda x: -x[0])
|
||||
|
||||
if not candidates:
|
||||
shortages.append({
|
||||
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
|
||||
'required_qty': remaining, 'allocated_qty': 0, 'missing': remaining,
|
||||
})
|
||||
continue
|
||||
|
||||
for avail, source_table, type_key, type_label, s in candidates:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, avail)
|
||||
if take <= 0:
|
||||
continue
|
||||
|
||||
d = s.to_dict()
|
||||
d['stock_id'] = s.id
|
||||
d['source_table'] = source_table
|
||||
d['type'] = type_key
|
||||
d['stock_type'] = type_key
|
||||
d['typeLabel'] = type_label
|
||||
d['uniqueKey'] = f"{type_key}_{s.id}"
|
||||
d['name'] = d.get('material_name') or (s.base.name if s.base else '') or ''
|
||||
d['standard'] = d.get('spec_model') or (s.base.spec_model if s.base else '') or ''
|
||||
d['warehouse_location'] = getattr(s, 'warehouse_location', '') or ''
|
||||
d['available_quantity'] = float(s.available_quantity or 0)
|
||||
d['allocated_qty'] = take # ★ 本次分配给该行的数量
|
||||
d['export_quantity'] = take # 兼容购物车字段名
|
||||
|
||||
# Fail-Closed: 剥离价格成本字段
|
||||
for k in ('unit_price', 'post_tax_unit_price', 'pre_tax_unit_price', 'total_price',
|
||||
'tax_rate', 'currency', 'exchange_rate', 'sale_price',
|
||||
'raw_material_cost', 'manual_cost', 'unit_total_cost'):
|
||||
d.pop(k, None)
|
||||
|
||||
allocated_items.append(d)
|
||||
remaining -= take
|
||||
|
||||
if remaining > 0:
|
||||
shortages.append({
|
||||
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
|
||||
'required_qty': req['required_qty'],
|
||||
'allocated_qty': req['required_qty'] - remaining,
|
||||
'missing': remaining,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'items': allocated_items,
|
||||
'shortages': shortages,
|
||||
'summary': {
|
||||
'requested': len(reqs),
|
||||
'allocated_kinds': len({i['base_id'] for i in allocated_items}),
|
||||
'shortage_kinds': len(shortages),
|
||||
},
|
||||
}
|
||||
}), 200
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# BOM 匹配库存接口 (POST /api/v1/outbound/bom-match-stock)
|
||||
# 替代前端 while(true) 全量加载:服务端按 child_ids 精确查询匹配库存
|
||||
#
|
||||
# ★ 两种用法:
|
||||
# 1) 分配模式(推荐):传 requirements=[{base_id, required_qty, ...}]
|
||||
# 后端在 DB 层完成「跨批次分配」,返回精确的 (stock_id, source_table, allocated_qty)
|
||||
# 2) 查询模式(兼容旧调用):传 child_ids=[...],返回该批 base_id 的全部库存行
|
||||
#
|
||||
# 为什么分配必须在后端做
|
||||
# ----------------------
|
||||
# 分配需要「该 base_id 的全部可用库存行」这一完整视图,且必须与出库扣减
|
||||
# (outbound_service.create_outbound_batch 按 stock_id 逐行 with_for_update 扣减)
|
||||
# 使用同一套数据。放在前端会引入两类必然故障:
|
||||
# · 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖;
|
||||
# · base_id 与 stock_id 的类型/精度差异会导致匹配落空,静默算成"缺料"。
|
||||
# 后端直接查库分配,从根本上消除上述不确定性。
|
||||
# ==============================================================================
|
||||
@outbound_bp.route('/bom-match-stock', methods=['POST'])
|
||||
@jwt_required()
|
||||
def bom_match_stock():
|
||||
"""
|
||||
根据 BOM 子件 base_id 列表,查询三张库存表中有库存的匹配记录。
|
||||
BOM 库存匹配 / 分配。
|
||||
|
||||
Body: { "child_ids": [1, 2, 3, ...] }
|
||||
Returns: { "code": 200, "data": { "items": [...] } }
|
||||
分配模式 Body:
|
||||
{
|
||||
"requirements": [
|
||||
{"base_id": 123, "required_qty": 10, "name": "...", "spec_model": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
Returns:
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"items": [ # 已分配好的库存行,前端可直接入购物车
|
||||
{"base_id", "stock_id", "source_table", "allocated_qty",
|
||||
"available_quantity", "sku", "name", ..., "shortage": 0}
|
||||
],
|
||||
"shortages": [{"base_id", "name", "required_qty", "allocated_qty", "missing"}]
|
||||
}
|
||||
}
|
||||
|
||||
查询模式 Body: { "child_ids": [1, 2, 3] } → 返回全部匹配库存行(旧行为)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
requirements = data.get('requirements')
|
||||
child_ids = data.get('child_ids', [])
|
||||
if not child_ids:
|
||||
return jsonify({'code': 400, 'msg': 'child_ids 不能为空'}), 400
|
||||
|
||||
# 去重
|
||||
child_ids = list(set(int(x) for x in child_ids))
|
||||
if not requirements and not child_ids:
|
||||
return jsonify({'code': 400, 'msg': 'requirements 或 child_ids 不能为空'}), 400
|
||||
|
||||
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
@ -241,6 +413,21 @@ def bom_match_stock():
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ★ 分配模式:后端完成跨批次分配
|
||||
# ------------------------------------------------------------------
|
||||
if requirements:
|
||||
return _allocate_bom_requirements(
|
||||
requirements, company_limit,
|
||||
StockBuy, StockSemi, StockProduct, MaterialBase,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 查询模式(兼容旧调用):返回全部匹配库存行
|
||||
# ------------------------------------------------------------------
|
||||
# 去重
|
||||
child_ids = list(set(int(x) for x in child_ids))
|
||||
|
||||
all_items = []
|
||||
|
||||
# 采购件
|
||||
|
||||
Reference in New Issue
Block a user