diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 5f5573d..4b27382 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -62,17 +62,22 @@ def filter_item_by_permissions(item_dict, user_permissions): @jwt_required() @permission_required('bom_manage') def get_bom_list(): - """获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤""" + """获取 BOM 列表,支持 keyword、active_only、category 过滤和分页""" try: keyword = request.args.get('keyword', '').strip() - # 将字符串 'true' 转为布尔值 active_only = request.args.get('active_only', 'false').lower() == 'true' + category = request.args.get('category', '').strip() or None + page = request.args.get('page', 1, type=int) + limit = request.args.get('pageSize', 15, type=int) - data = BomService.get_bom_list(keyword=keyword, active_only=active_only) - # 字段级脱敏 + data = BomService.get_bom_list( + keyword=keyword, active_only=active_only, + category=category, page=page, limit=limit + ) + # 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典) user_permissions = get_current_user_permissions() - if isinstance(data, list): - data = [filter_item_by_permissions(item, user_permissions) for item in data] + if data.get('items'): + data['items'] = [filter_item_by_permissions(item, user_permissions) for item in data['items']] return jsonify({ 'code': 200, 'msg': 'success', @@ -83,6 +88,23 @@ def get_bom_list(): return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 +# ============================================================================== +# BOM 分组摘要接口 (GET /api/v1/bom/summary) +# 极轻量查询:仅 GROUP BY category + COUNT(DISTINCT bom_no+version) +# ============================================================================== +@bom_bp.route('/summary', methods=['GET']) +@jwt_required() +@permission_required('bom_manage') +def get_bom_summary(): + try: + keyword = request.args.get('keyword', '').strip() or None + data = BomService.get_bom_summary(keyword=keyword) + return jsonify({'code': 200, 'msg': 'success', 'data': data}) + except Exception as e: + current_app.logger.error(f'获取BOM摘要失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + + @bom_bp.route('/detail/', methods=['GET']) @jwt_required() @permission_required('bom_manage') diff --git a/inventory-backend/app/api/v1/inbound/base.py b/inventory-backend/app/api/v1/inbound/base.py index 0ec2cc4..e15ff58 100644 --- a/inventory-backend/app/api/v1/inbound/base.py +++ b/inventory-backend/app/api/v1/inbound/base.py @@ -168,6 +168,31 @@ def get_list(): return jsonify({"code": 500, "msg": str(e)}), 500 +# ============================================================================== +# 1.4 Odoo 分组摘要接口 (GET /api/v1/inbound/base/odoo-summary) +# 极轻量查询:仅 GROUP BY category + COUNT,不 JOIN 任何库存表 +# ============================================================================== +@inbound_base_bp.route('/odoo-summary', methods=['GET']) +@permission_required('material_list') +def get_odoo_summary(): + try: + keyword = request.args.get('keyword', '').strip() or None + is_enabled_raw = request.args.get('isEnabled', None) + is_enabled = None + if is_enabled_raw is not None: + val = str(is_enabled_raw).lower() + if val in ('1', 'true', 'yes', 't'): + is_enabled = True + elif val in ('0', 'false', 'no', 'f'): + is_enabled = False + + data = MaterialBaseService.get_odoo_summary(keyword=keyword, is_enabled=is_enabled) + return jsonify({"code": 200, "msg": "success", "data": data}) + except Exception as e: + traceback.print_exc() + return jsonify({"code": 500, "msg": str(e)}), 500 + + # ============================================================================== # 2.1 选项接口 (GET /api/v1/inbound/base/options) # ============================================================================== diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index cc2fadd..e83fd05 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -67,14 +67,24 @@ bp = Blueprint('stock_ops', __name__) # ============================================================ # 辅助函数:获取库存记录 # ============================================================ -def get_stock_record(source_table, stock_id): - """根据库存类型和ID获取库存记录""" +def get_stock_record(source_table, stock_id, for_update=False): + """根据库存类型和ID获取库存记录 + + Args: + source_table: 库存类型 ('stock_buy' / 'stock_semi' / 'stock_product') + stock_id: 库存记录主键ID + for_update: 是否使用 SELECT ... FOR UPDATE 悲观行锁(默认 False) + 设为 True 时可防止并发调整/报废导致的 TOCTOU 竞态 + """ if source_table == 'stock_buy' and StockBuy: - return StockBuy.query.get(stock_id) + q = StockBuy.query + return (q.with_for_update() if for_update else q).get(stock_id) elif source_table == 'stock_semi' and StockSemi: - return StockSemi.query.get(stock_id) + q = StockSemi.query + return (q.with_for_update() if for_update else q).get(stock_id) elif source_table == 'stock_product' and StockProduct: - return StockProduct.query.get(stock_id) + q = StockProduct.query + return (q.with_for_update() if for_update else q).get(stock_id) return None @@ -704,8 +714,8 @@ def adjust_stock(): return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400 diff_qty = float(diff_qty) - # 3. 获取并校验真实的库存记录 - stock = get_stock_record(source_table, stock_id) + # 3. 获取并校验真实的库存记录 — ★ 修复并发:使用悲观锁防止 TOCTOU + stock = get_stock_record(source_table, stock_id, for_update=True) if not stock: return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404 @@ -1061,18 +1071,18 @@ def export_stocktake(): session_drafts = StocktakeDraft.query.all() scanned_set = {(d.source_table, d.stock_id) for d in session_drafts} - def get_borrowed_qty(source_table, stock_id): - """获取某库存的借出未还数量""" - try: - borrowed = TransBorrow.query.filter( - TransBorrow.source_table == source_table, - TransBorrow.stock_id == stock_id, - TransBorrow.is_returned == False - ).all() - total = sum(float(b.quantity or 0) - float(b.returned_quantity or 0) for b in borrowed) - return total - except: - return 0 + # ★ 性能优化:批量预取所有未还借用的聚合数量 + # 单条 GROUP BY 查询替代循环内逐条 get_borrowed_qty() N+1 + borrow_rows = db.session.query( + TransBorrow.source_table, + TransBorrow.stock_id, + func.sum(func.coalesce(TransBorrow.quantity, 0) - func.coalesce(TransBorrow.returned_quantity, 0)).label('pending') + ).filter( + TransBorrow.is_returned == False + ).group_by( + TransBorrow.source_table, TransBorrow.stock_id + ).all() + borrow_map = {(r.source_table, r.stock_id): float(r.pending or 0) for r in borrow_rows} unscanned_items = [] @@ -1081,8 +1091,8 @@ def export_stocktake(): key = ('stock_buy', stock.id) if key in scanned_set: continue - # 扣除外借数量 - borrowed_qty = get_borrowed_qty('stock_buy', stock.id) + # ★ 扣除外借数量:O(1) 字典查找替代逐条 TransBorrow 查询 + borrowed_qty = borrow_map.get(key, 0) stock_qty = float(stock.stock_quantity or 0) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: @@ -1115,7 +1125,7 @@ def export_stocktake(): key = ('stock_semi', stock.id) if key in scanned_set: continue - borrowed_qty = get_borrowed_qty('stock_semi', stock.id) + borrowed_qty = borrow_map.get(key, 0) stock_qty = float(stock.stock_quantity or 0) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: @@ -1151,7 +1161,7 @@ def export_stocktake(): stock_qty = float(stock.stock_quantity or 0) if stock_qty <= 0: continue - borrowed_qty = get_borrowed_qty('stock_product', stock.id) + borrowed_qty = borrow_map.get(key, 0) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: # ★ 直接使用预加载的 base 关系,避免额外查询 diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 983e519..9dc86b5 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -261,19 +261,19 @@ class ScrapService: created_records.append(scrap_record) continue - # 获取库存记录 + # 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存 stock_record = None if source_table == 'stock_product': - stock_record = StockProduct.query.get(stock_id) + stock_record = StockProduct.query.with_for_update().get(stock_id) elif source_table == 'stock_semi': - stock_record = StockSemi.query.get(stock_id) + stock_record = StockSemi.query.with_for_update().get(stock_id) elif source_table == 'stock_buy': - stock_record = StockBuy.query.get(stock_id) + stock_record = StockBuy.query.with_for_update().get(stock_id) if not stock_record: raise ValueError(f'库存记录不存在: ID={stock_id}') - # 检查可用数量 + # 检查可用数量(锁已持有,TOCTOU 窗口已消除) avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0 if avail_qty < scrap_qty: raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}") diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index 9addc97..2941fb3 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -110,124 +110,162 @@ class BomService: return f'BOM-{timestamp}-{unique}' @staticmethod - def get_bom_list(keyword=None, active_only=False): + def get_bom_list(keyword=None, active_only=False, category=None, page=1, limit=15): """ - 获取所有 BOM 配方(按 bom_no + version 分组) - 支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格 + 获取所有 BOM 配方(按 bom_no + version 分组,单条 SQL 聚合 + 分页) + + 性能优化(v2): + - 消除 N+1:单条 GROUP BY + string_agg 查询替代循环内逐条查询 + - 消除全量加载:DB 层 .paginate() 替代 .all() + Python 内存分页 + - 消除 Python 二次过滤:关键词过滤完全下沉到 SQL(子查询 + EXISTS 语义) + + Args: + category: 可选,按 parent_category 精确过滤(用于懒加载分组展开) """ - # 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合 - query_base = db.session.query( + child_alias = db.aliased(MaterialBase) + + # ===== 主聚合查询(单条 SQL,GROUP BY + string_agg) ===== + query = db.session.query( BomTable.bom_no, - BomTable.version + BomTable.version, + BomTable.parent_id, + MaterialBase.name.label('parent_name'), + MaterialBase.spec_model.label('parent_spec'), + MaterialBase.category.label('parent_category'), + BomTable.is_enabled, + func.count(BomTable.child_id).label('child_count'), + func.string_agg(child_alias.name, ', ').label('child_names'), + func.string_agg(child_alias.spec_model, ', ').label('child_specs') ).join( MaterialBase, BomTable.parent_id == MaterialBase.id + ).outerjoin( + child_alias, BomTable.child_id == child_alias.id + ).group_by( + BomTable.bom_no, BomTable.version, BomTable.parent_id, + MaterialBase.name, MaterialBase.spec_model, MaterialBase.category, + BomTable.is_enabled ) - # ★ 过滤禁用状态 + # 过滤禁用状态 if active_only: - query_base = query_base.filter(BomTable.is_enabled == True) + query = query.filter(BomTable.is_enabled == True) # 【行级数据隔离】基于 JWT 多租户公司过滤 company_limit = get_current_company_filter() if company_limit is not None: - query_base = query_base.filter(MaterialBase.company_name == company_limit) + query = query.filter(MaterialBase.company_name == company_limit) + # 按类别过滤(用于懒加载分组展开) + if category: + query = query.filter(MaterialBase.category == category) + + # ===== 关键词过滤(完全下沉到 SQL,消除 Python 二次过滤) ===== if keyword: - kw = f'%{keyword}%' - # 关联子件表以支持子件搜索 - child_alias = db.aliased(MaterialBase) - query_base = query_base.outerjoin( - child_alias, BomTable.child_id == child_alias.id + kw = f'%{keyword.strip()}%' + # 子查询:找到所有匹配的 (bom_no, version) 对 + kw_child_alias = db.aliased(MaterialBase) + match_subq = db.session.query( + BomTable.bom_no, + BomTable.version + ).join( + MaterialBase, BomTable.parent_id == MaterialBase.id + ).outerjoin( + kw_child_alias, BomTable.child_id == kw_child_alias.id ).filter( or_( BomTable.bom_no.ilike(kw), MaterialBase.name.ilike(kw), MaterialBase.spec_model.ilike(kw), - child_alias.name.ilike(kw), - child_alias.spec_model.ilike(kw) + kw_child_alias.name.ilike(kw), + kw_child_alias.spec_model.ilike(kw) + ) + ).distinct().subquery() + + # 用子查询结果过滤主查询 + query = query.join( + match_subq, + db.and_( + BomTable.bom_no == match_subq.c.bom_no, + BomTable.version == match_subq.c.version ) ) - # ★ 调试:打印 SQL 语句 - logger.info(f"[BOM List] keyword={keyword!r} → SQL:\n{str(query_base.statement.compile(compile_kwargs={'literal_binds': True}))}") + # 排序(最新在前) + query = query.order_by(BomTable.bom_no.desc(), BomTable.version.desc()) - # 获取符合条件的唯一组合 - target_pairs = query_base.distinct().all() + # ===== 数据库层分页(不再 .all() 到内存) ===== + pagination = query.paginate(page=page, per_page=limit, error_out=False) - if not target_pairs: - return [] - - # 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称,解决步骤3过滤遗漏问题) - results = [] - for bom_no, version in target_pairs: - # ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格 - child_alias = db.aliased(MaterialBase) - summary = db.session.query( - BomTable.parent_id, - MaterialBase.name.label('parent_name'), - MaterialBase.spec_model.label('parent_spec'), - MaterialBase.category.label('parent_category'), - BomTable.is_enabled, - func.count(BomTable.child_id).label('child_count'), - # ★ 聚合子件名称为逗号分隔字符串(用于步骤3关键词过滤) - func.string_agg(child_alias.name, ', ').label('child_names'), - # ★ 同时聚合子件规格(备用) - func.string_agg(child_alias.spec_model, ', ').label('child_specs') - ).join( - MaterialBase, BomTable.parent_id == MaterialBase.id - ).outerjoin( - child_alias, BomTable.child_id == child_alias.id - ).filter( - BomTable.bom_no == bom_no, - BomTable.version == version - ).group_by( - BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, MaterialBase.category, BomTable.is_enabled - ).first() - - if summary: - results.append({ - 'bom_no': bom_no, - 'version': version, - 'parent_id': summary.parent_id, - 'parent_name': summary.parent_name, - 'parent_spec': summary.parent_spec or '', - 'parent_category': summary.parent_category or '', - 'is_enabled': summary.is_enabled, - 'child_count': summary.child_count, - 'child_names': summary.child_names or '', # ★ 新增:子件名称聚合 - 'child_specs': summary.child_specs or '' # ★ 新增:子件规格聚合 - }) - - results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True) - - # 如果有关键词,二次过滤结果(忽略大小写) - if keyword: - kw = keyword.lower() - results = [ - r for r in results - if kw in (r.get('parent_name') or '').lower() - or kw in (r.get('parent_spec') or '').lower() - or kw in (r.get('bom_no') or '').lower() - or kw in (r.get('parent_category') or '').lower() - or kw in (r.get('child_names') or '').lower() # ★ 修复:加入子件名称过滤 - or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤 - ] - - # 按 parent_category 分组 - grouped = defaultdict(list) - for item in results: - cat = item.get('parent_category') or '未分类' - grouped[cat].append(item) - - grouped_list = [] - for cat, items in sorted(grouped.items(), key=lambda x: x[0]): - grouped_list.append({ - 'category': cat, - 'count': len(items), - 'items': items + # 组装结果 + items = [] + for row in pagination.items: + items.append({ + 'bom_no': row.bom_no, + 'version': row.version, + 'parent_id': row.parent_id, + 'parent_name': row.parent_name, + 'parent_spec': row.parent_spec or '', + 'parent_category': row.parent_category or '', + 'is_enabled': row.is_enabled, + 'child_count': row.child_count, + 'child_names': row.child_names or '', + 'child_specs': row.child_specs or '' }) - return grouped_list + return { + 'items': items, + 'total': pagination.total, + 'pages': pagination.pages, + 'current_page': page + } + + @staticmethod + def get_bom_summary(keyword=None): + """ + BOM 分组摘要 API(极轻量,单条 GROUP BY + COUNT) + + SQL: + SELECT m.category, COUNT(DISTINCT (b.bom_no, b.version)) AS count + FROM bom_table b + JOIN material_base m ON b.parent_id = m.id + WHERE ... + GROUP BY m.category + ORDER BY m.category + + Returns: + [{"category": "IRIS/半成品/无人机U", "count": 15}, ...] + """ + query = db.session.query( + MaterialBase.category, + func.count(func.distinct( + func.concat(BomTable.bom_no, '|', BomTable.version) + )).label('count') + ).join( + MaterialBase, BomTable.parent_id == MaterialBase.id + ) + + # 行级数据隔离 + company_limit = get_current_company_filter() + if company_limit is not None: + query = query.filter(MaterialBase.company_name == company_limit) + + # 关键词搜索 + if keyword: + kw = f'%{keyword.strip()}%' + query = query.filter(or_( + BomTable.bom_no.ilike(kw), + MaterialBase.name.ilike(kw), + MaterialBase.spec_model.ilike(kw) + )) + + query = query.group_by(MaterialBase.category) \ + .order_by(MaterialBase.category) + + rows = query.all() + return [ + {"category": r.category or "未分类", "count": r.count} + for r in rows + ] @staticmethod def get_bom_detail(bom_no, version=None): diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index 4c02766..d6dcf7b 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -476,6 +476,62 @@ class MaterialBaseService: print(f"查询基础信息列表失败: {e}") return {"total": 0, "items": []} + @staticmethod + def get_odoo_summary(keyword=None, is_enabled=None): + """ + Odoo 分组摘要 API(极轻量,不 JOIN 任何库存表) + + 执行: + SELECT category, COUNT(id) AS count + FROM material_base + WHERE ... + GROUP BY category + ORDER BY category + + 返回: + [{"category": "IRIS/半成品/高塔监测T", "count": 54}, ...] + """ + try: + query = db.session.query( + MaterialBase.category, + func.count(MaterialBase.id).label('count') + ) + + # 状态过滤 + if is_enabled is not None: + query = query.filter(MaterialBase.is_enabled == is_enabled) + + # 关键词搜索(与 get_list 行为一致) + if keyword: + kw = f'%{keyword.strip()}%' + query = query.filter(or_( + MaterialBase.name.ilike(kw), + MaterialBase.common_name.ilike(kw), + MaterialBase.spec_model.ilike(kw) + )) + + # 行级数据隔离 + from app.utils.decorators import get_current_company_filter + company_limit = get_current_company_filter() + if company_limit is not None: + query = query.filter(MaterialBase.company_name == company_limit) + + # GROUP BY + ORDER + query = query.group_by(MaterialBase.category) \ + .order_by(MaterialBase.category) + + rows = query.all() + + return [ + {"category": row.category or "未分类", "count": row.count} + for row in rows + ] + + except Exception as e: + traceback.print_exc() + print(f"查询 Odoo 摘要失败: {e}") + return [] + @staticmethod def get_distinct_options(): """ diff --git a/inventory-backend/app/services/inventory_task.py b/inventory-backend/app/services/inventory_task.py index 9d3729a..e1011f3 100644 --- a/inventory-backend/app/services/inventory_task.py +++ b/inventory-backend/app/services/inventory_task.py @@ -23,20 +23,65 @@ from app.models.inbound.product import StockProduct class InventoryWarningService: @staticmethod - def _get_total_inventory(base_id: int) -> float: + def _prefetch_inventory_map(settings): """ - 计算指定物料在所有库存表(采购件 + 半成品 + 成品)中的总库存量 + 批量预取所有预警物料的库存总计(单条 SQL,跨三表聚合) + + 性能优化(v2): + - 消除 N+1:用 UNION ALL + GROUP BY 替代循环内逐条 scalar 查询 + - 消除额外的 MaterialBase.get():批量返回 name / spec_model + + Returns: + dict[int, dict]: {base_id: {'inv': float, 'avail': float, 'name': str, 'spec': str}, ...} """ - buy_q = db.session.query(func.sum(StockBuy.stock_quantity)).filter( - StockBuy.base_id == base_id - ).scalar() or 0 - semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter( - StockSemi.base_id == base_id - ).scalar() or 0 - prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter( - StockProduct.base_id == base_id - ).scalar() or 0 - return float(buy_q) + float(semi_q) + float(prod_q) + from sqlalchemy import text + + base_ids = list({s.base_id for s in settings}) + + if not base_ids: + return {} + + # ── 单条 SQL:三表 UNION ALL → 外层 GROUP BY ── + sql = text(""" + SELECT base_id, + SUM(stock_qty) AS total_stock, + SUM(avail_qty) AS total_avail + FROM ( + SELECT base_id, stock_quantity AS stock_qty, available_quantity AS avail_qty + FROM stock_buy + WHERE base_id = ANY(:ids) + + UNION ALL + + SELECT base_id, stock_quantity, available_quantity + FROM stock_semi + WHERE base_id = ANY(:ids) + + UNION ALL + + SELECT base_id, stock_quantity, available_quantity + FROM stock_product + WHERE base_id = ANY(:ids) + ) AS combined + GROUP BY base_id + """) + rows = db.session.execute(sql, {'ids': base_ids}).fetchall() + + # 批量查询 MaterialBase + materials = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all() + mat_map = {m.id: {'name': m.name, 'spec': m.spec_model or ''} for m in materials} + + result = {} + for row in rows: + bid = row.base_id + mat = mat_map.get(bid, {'name': '', 'spec': ''}) + result[bid] = { + 'inv': float(row.total_stock or 0), + 'avail': float(row.total_avail or 0), + 'name': mat['name'], + 'spec': mat['spec'] + } + return result @staticmethod def _parse_emails(email_str: str) -> list: @@ -106,17 +151,20 @@ class InventoryWarningService: sent_yellow = False processed_settings = [] + # ★ 性能优化:批量预取所有 setting 对应的库存 + 物料信息(1 条 SQL 替代 N*4 条) + inv_map = InventoryWarningService._prefetch_inventory_map(settings) + for setting in settings: base_id = setting.base_id - material = MaterialBase.query.get(base_id) - if not material: + mat_data = inv_map.get(base_id) + if not mat_data: continue - name = material.name - spec = material.spec_model or '' + name = mat_data['name'] + spec = mat_data['spec'] + inv = mat_data['inv'] red_th = float(setting.red_threshold) if setting.red_threshold is not None else None yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None - inv = InventoryWarningService._get_total_inventory(base_id) # ★ 红色预警:库存 <= red_threshold,走 setting.red_emails ★ if red_th is not None and inv <= red_th: diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 8f04bc0..ad20ccc 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -254,19 +254,22 @@ class OutboundService: ) db.session.add(new_record) - # ★ 出库后检查低库存预警 - try: - from app.services.inventory_task import InventoryWarningService - InventoryWarningService.check_and_send_warning_emails() - except Exception as e: - current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}") - # ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成" if approval: approval.status = 3 # 3-已完成 # updated_at 会在 commit 时由 SQLAlchemy 自动更新 + # ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间 db.session.commit() + + # ★ 出库后检查低库存预警(移到 commit 之后,避免事务内网络调用) + try: + from app.services.inventory_task import InventoryWarningService + InventoryWarningService.check_and_send_warning_emails() + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"⚠️ 低库存预警检查失败: {e}") + return outbound_no except Exception as e: diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 8d1b78b..f0b0c80 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -85,6 +85,9 @@ class TransService: borrow_no = TransService.generate_borrow_no() model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} + # ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁 + items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0))) + try: for item in items: source_table = item.get('source_table') diff --git a/inventory-web/src/api/bom.ts b/inventory-web/src/api/bom.ts index 8771232..efa1b00 100644 --- a/inventory-web/src/api/bom.ts +++ b/inventory-web/src/api/bom.ts @@ -9,6 +9,15 @@ export function getBomList(params?: any) { }) } +// 获取BOM分组摘要(懒加载用,轻量 GROUP BY category + COUNT) +export function getBomSummary(params?: { keyword?: string }) { + return request({ + url: '/v1/bom/summary', + method: 'get', + params + }) +} + // 获取BOM详情(含库存信息) export function getBomWithStock(bomNo: string) { const trimmed = bomNo.replace(/^\/+|\/+$/g, ''); diff --git a/inventory-web/src/api/material_base.ts b/inventory-web/src/api/material_base.ts index c5c816e..4f315a1 100644 --- a/inventory-web/src/api/material_base.ts +++ b/inventory-web/src/api/material_base.ts @@ -94,4 +94,13 @@ export function getMaterialUnitsAPI() { url: '/inbound/base/units', method: 'get' }) +} + +// 10. Odoo 分组摘要(轻量 GROUP BY category + COUNT,不 JOIN 库存表) +export function getOdooSummary(params?: { keyword?: string; isEnabled?: boolean }) { + return request({ + url: '/inbound/base/odoo-summary', + method: 'get', + params + }) } \ No newline at end of file diff --git a/inventory-web/src/views/bom/BomManage.vue b/inventory-web/src/views/bom/BomManage.vue index 21b4435..e24b563 100644 --- a/inventory-web/src/views/bom/BomManage.vue +++ b/inventory-web/src/views/bom/BomManage.vue @@ -17,30 +17,30 @@ - 全部展开 - 全部折叠 + 全部展开 + 全部折叠 新建 BOM - - - + + + - + - - + + - + +
+ 加载中... +
@@ -254,7 +257,7 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus' import { Plus, Search, EditPen } from '@element-plus/icons-vue' import { useRouter } from 'vue-router' -import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom' +import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom' import { searchMaterialBase } from '@/api/inbound/buy' import { useUserStore } from '@/stores/user' @@ -314,11 +317,27 @@ let originalChildren: ChildRow[] = [] let pendingDraftBomNo = '' let pendingDraftVersion = '' -const bomGroups = ref([]) // 分组结构: [{category, count, items[]}] -const activeCategories = ref([]) // 默认全部展开 const searchKeyword = ref('') const childSearchKeyword = ref('') +// ★ 懒加载分组架构 +interface GroupSummary { category: string; count: number } +const groupSummary = ref([]) +const groupCache = ref>(new Map()) +const groupLoadingMap = ref>(new Map()) +const activeCategories = ref([]) +const lastKeyword = ref('') + +const groupedData = computed(() => { + if (!groupSummary.value.length) return [] + return groupSummary.value.map(s => ({ + category: s.category, + count: s.count, + items: groupCache.value.get(s.category) ?? [], + loaded: groupCache.value.has(s.category) + })) +}) + const filteredChildren = computed(() => { if (!childSearchKeyword.value) return form.children const kw = childSearchKeyword.value.toLowerCase() @@ -330,11 +349,10 @@ const filteredChildren = computed(() => { }) // 自动搜索:输入后 500ms 防抖触发搜索(无需回车) -watch(searchKeyword, (val) => { - // 防抖:延迟 500ms 执行,避免频繁请求 +watch(searchKeyword, () => { clearTimeout((window as any)._bomSearchTimer) ;(window as any)._bomSearchTimer = setTimeout(() => { - fetchBomList() + fetchBomSummary() }, 500) }) @@ -465,7 +483,8 @@ const pureBomNo = computed(() => form.bom_no) const versionOptions = computed(() => { const ver = originalVersion || 'V1.0' - const allItems = bomGroups.value.flatMap((g: any) => g.items) + const allItems: any[] = [] + groupCache.value.forEach((items) => allItems.push(...items)) const occupiedVersions = new Set( allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version) ) @@ -500,18 +519,61 @@ const rules = reactive({ const dialogTitle = ref('新建 BOM') const handleSearch = () => { - activeCategories.value = [] // 用户主动搜索时重置折叠状态 - fetchBomList() + activeCategories.value = [] + groupCache.value = new Map() + fetchBomSummary() } -const fetchBomList = async () => { +// ★ 挂载+搜索时调用 — 获取分组摘要 +const fetchBomSummary = async () => { loading.value = true try { - const res = await getBomList({ keyword: searchKeyword.value }) - if (res.code === 200) { - bomGroups.value = res.data + const params: any = {} + if (searchKeyword.value) params.keyword = searchKeyword.value + const res: any = await getBomSummary(params) + if (res?.code === 200) { + groupSummary.value = res.data ?? [] + if (searchKeyword.value !== lastKeyword.value) { + activeCategories.value = [] + lastKeyword.value = searchKeyword.value + } } - } finally { loading.value = false } + } catch (e) { console.error('获取BOM摘要失败', e) } + finally { loading.value = false } +} + +// ★ 默认全部展开(用户点击按钮时) +const expandAllGroups = () => { + activeCategories.value = groupSummary.value.map(g => g.category) + groupSummary.value.forEach(g => loadGroupItems(g.category)) +} +const collapseAllGroups = () => { activeCategories.value = [] } + +// ★ 展开分组时懒加载该分类下的 BOM +const loadGroupItems = async (category: string) => { + if (groupCache.value.has(category)) return + if (groupLoadingMap.value.get(category)) return + + groupLoadingMap.value.set(category, true) + try { + const params: any = { + category: category, + page: 1, + pageSize: 9999 // 单分类内全量加载(分类内数据量可控) + } + if (searchKeyword.value) params.keyword = searchKeyword.value + const res: any = await getBomList(params) + if (res?.code === 200) { + groupCache.value.set(category, res.data?.items ?? []) + } + } catch (e) { console.error(`加载BOM分类 [${category}] 失败`, e) } + finally { groupLoadingMap.value.set(category, false) } +} + +// ★ collapse @change 事件 → 触发懒加载 +const handleCollapseChange = (val: string | string[]) => { + const cats = Array.isArray(val) ? val : (val ? [val] : []) + cats.forEach(c => loadGroupItems(c)) } const onParentChange = (val: number) => {} @@ -790,7 +852,7 @@ const handleDelete = (row: BomItem) => { .then(async () => { try { const res = await deleteBom(row.bom_no, row.version) - if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() } + if (res.code === 200) { ElMessage.success('删除成功'); groupCache.value = new Map(); fetchBomSummary() } } catch (e) {} }) .catch(() => {}) @@ -872,7 +934,8 @@ const submitForm = async () => { localStorage.removeItem('pending_bom_draft_version') originalDraftHash.value = '' dialogVisible.value = false - fetchBomList() + groupCache.value = new Map() + fetchBomSummary() } else { ElMessage.error(res.msg || '保存失败') } @@ -891,22 +954,19 @@ onMounted(() => { // 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料 searchKeyword.value = parentName; - fetchBomList(); + fetchBomSummary(); // 2. 延迟等待基础渲染后进行查重 setTimeout(() => { - getBomList({ keyword: parentName }).then((res: any) => { - const groups = res.data || []; + getBomList({ keyword: parentName, page: 1, pageSize: 50 }).then((res: any) => { + // ★ 适配新 API 格式:{ items: [...], total, pages } + const flatItems = res.data?.items ?? []; let existingBom = null; - // ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id - for (const group of groups) { - if (group.items && group.items.length > 0) { - const found = group.items.find((b: any) => b.parent_id === parentId); - if (found) { - existingBom = found; - break; // 找到了就跳出循环 - } + for (const bom of flatItems) { + if (bom.parent_id === parentId) { + existingBom = bom; + break; } } @@ -930,8 +990,8 @@ onMounted(() => { }); }, 300); } else { - // 如果不是从其他页面跳转过来的,直接正常加载全部列表 - fetchBomList(); + // 如果不是从其他页面跳转过来的,直接正常加载摘要 + fetchBomSummary(); } }) diff --git a/inventory-web/src/views/borrow/apply/index.vue b/inventory-web/src/views/borrow/apply/index.vue index f18764b..6f55033 100644 --- a/inventory-web/src/views/borrow/apply/index.vue +++ b/inventory-web/src/views/borrow/apply/index.vue @@ -708,8 +708,18 @@ const openBomSelect = async () => { selectedBomNo.value = '' currentBomDetail.value = [] try { - const res = await getBomList({ active_only: true }) - bomOptions.value = res.data || [] + // ★ 适配新 API 格式 { items: [...], total, pages } → 前端按 parent_category 分组 + const res = await getBomList({ active_only: true, pageSize: 9999 }) + const flatItems = res.data?.items ?? [] + const groupMap = new Map() + for (const item of flatItems) { + const cat = item.parent_category || '未分类' + if (!groupMap.has(cat)) groupMap.set(cat, []) + groupMap.get(cat)!.push(item) + } + bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({ + category, count: items.length, items + })) } catch (e) { ElMessage.error('加载 BOM 列表失败') } diff --git a/inventory-web/src/views/material/buyOdoo.vue b/inventory-web/src/views/material/buyOdoo.vue index 354f7f4..a5b6550 100644 --- a/inventory-web/src/views/material/buyOdoo.vue +++ b/inventory-web/src/views/material/buyOdoo.vue @@ -204,7 +204,11 @@
- +
- {{ group.category || '未分类' }} ({{ group.items.length }}) + {{ group.category || '未分类' }} ({{ group.count }}) +
@@ -698,7 +703,7 @@