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
|
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)
|
# 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'])
|
@outbound_bp.route('/bom-match-stock', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def bom_match_stock():
|
def bom_match_stock():
|
||||||
"""
|
"""
|
||||||
根据 BOM 子件 base_id 列表,查询三张库存表中有库存的匹配记录。
|
BOM 库存匹配 / 分配。
|
||||||
|
|
||||||
Body: { "child_ids": [1, 2, 3, ...] }
|
分配模式 Body:
|
||||||
Returns: { "code": 200, "data": { "items": [...] } }
|
{
|
||||||
|
"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:
|
try:
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
requirements = data.get('requirements')
|
||||||
child_ids = data.get('child_ids', [])
|
child_ids = data.get('child_ids', [])
|
||||||
if not child_ids:
|
|
||||||
return jsonify({'code': 400, 'msg': 'child_ids 不能为空'}), 400
|
|
||||||
|
|
||||||
# 去重
|
if not requirements and not child_ids:
|
||||||
child_ids = list(set(int(x) for x in child_ids))
|
return jsonify({'code': 400, 'msg': 'requirements 或 child_ids 不能为空'}), 400
|
||||||
|
|
||||||
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
|
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
|
||||||
from app.utils.decorators import get_current_company_filter
|
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 app.models.inbound.product import StockProduct
|
||||||
from sqlalchemy.orm import joinedload
|
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 = []
|
all_items = []
|
||||||
|
|
||||||
# 采购件
|
# 采购件
|
||||||
|
|||||||
Reference in New Issue
Block a user