From a910a6ea72162f132224925a83f2644cf80a86f1 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 14:16:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(bom):=20=E5=90=8E=E7=AB=AF=E6=89=BF?= =?UTF-8?q?=E6=8E=A5=20BOM=20=E5=BA=93=E5=AD=98=E5=88=86=E9=85=8D=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=A4=9A=E6=89=B9=E6=AC=A1=E7=89=A9=E6=96=99?= =?UTF-8?q?=E5=8F=AA=E8=83=BD=E5=8A=A0=201=20=E4=BB=B6=E7=9A=84=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/ 按 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。 --- inventory-backend/app/api/v1/outbound.py | 203 ++++++++++++++++++++++- 1 file changed, 195 insertions(+), 8 deletions(-) diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 5fe0500..ab2c3c0 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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 = [] # 采购件