diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index d5f518b..faef504 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -1,6 +1,7 @@ import uuid # .material -> .base refactor checked from datetime import datetime, timezone, timedelta from sqlalchemy import or_, func, desc, and_ +from sqlalchemy.orm import joinedload from app.extensions import db from app.models.outbound import TransOutbound, OutboundApproval @@ -475,6 +476,37 @@ class OutboundService: 'stock_product': StockProduct } + # ========================================== + # ★ 优化步骤 1:第一遍循环,单纯收集所有的 stock_id + # ========================================== + stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} + + for d in details: + if d.source_table in stock_ids_by_table and d.stock_id: + stock_ids_by_table[d.source_table].add(d.stock_id) + + # ========================================== + # ★ 优化步骤 2:发起批量查询,并强制 JOIN 基础物料表 + # ========================================== + # 格式: { ('stock_buy', 101): stock_obj, ... } + preloaded_stocks = {} + + for table_name, ids in stock_ids_by_table.items(): + if not ids: + continue + + ModelClass = model_map[table_name] + # 魔法在这里:in_() 一次性查出所有库存,joinedload 顺便把 base 表的数据一起拉回来 + items = ModelClass.query.options( + joinedload(ModelClass.base) + ).filter(ModelClass.id.in_(ids)).all() + + for item in items: + preloaded_stocks[(table_name, item.id)] = item + + # ========================================== + # ★ 优化步骤 3:第二遍循环,纯内存拼装(极速) + # ========================================== for d in details: ono = d.outbound_no if ono not in grouped_map: @@ -490,34 +522,20 @@ class OutboundService: 'items': [] } - # --- 查询物品详细信息 (名称, 规格, 类型, 类别, 批号/SN) --- - item_name = "未知物品" - item_spec = "" - item_cat = "" - item_type = "" - batch_sn = "-" + # --- 直接从内存字典中获取,O(1) 复杂度,绝对不触发 SQL --- + item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-" - ModelClass = model_map.get(d.source_table) - if ModelClass and d.stock_id: - try: - stock_item = ModelClass.query.get(d.stock_id) - if stock_item: - # 获取批号/序列号用于追溯 - batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-' - if stock_item.base: - item_name = stock_item.base.name - item_spec = stock_item.base.spec_model - item_cat = stock_item.base.category - item_type = stock_item.base.material_type - elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id: - base_info = MaterialBase.query.get(stock_item.base_id) - if base_info: - item_name = base_info.name - item_spec = base_info.spec_model - item_cat = base_info.category - item_type = base_info.material_type - except Exception as e: - print(f"Error fetching detail for stock_id {d.stock_id}: {e}") + stock_item = preloaded_stocks.get((d.source_table, d.stock_id)) + + if stock_item: + batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-' + + # 因为前面用了 joinedload,这里调用 .base 瞬间返回,不会去查数据库 + if stock_item.base: + item_name = stock_item.base.name + item_spec = stock_item.base.spec_model + item_cat = stock_item.base.category + item_type = stock_item.base.material_type # 计算金额 price = float(d.unit_price) if d.unit_price else 0 diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index b4e6327..5edfc3a 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -114,12 +114,12 @@ class TransService: @staticmethod def process_return(data, operator_name): """ - 还库逻辑(支持部分归还): - 1. 校验本次归还数量不能大于待还数量 - 2. 恢复可用库存(按本次归还数量) - 3. 更新库位 (如果有变动) - 4. 记录库管签字 - 5. 更新归还数量和状态(部分归还/全部归还) + 还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险 + 四步走策略: + 1. 收集所有 borrow_id + 2. 批量锁定借用记录 + 3. 收集库存ID并批量锁定库存 + 4. 内存中完成业务逻辑 """ items = data.get('items', []) signature = data.get('signature_path') # 库管签字 @@ -130,15 +130,60 @@ class TransService: model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} try: + # ========================================== + # ★ 优化步骤 1:收集所有 borrow_id + # ========================================== + borrow_ids = [] + item_map = {} # 存储原始 item 数据,key=borrow_id for item in items: borrow_id = item.get('id') - # 前端传入的本次归还数量 - return_qty = float(item.get('return_qty', 0)) - # 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback - # 这里假设前端传来的 return_location 就是最终要保存的库位 - final_location = item.get('return_location') + if borrow_id: + borrow_ids.append(borrow_id) + item_map[borrow_id] = { + 'return_qty': float(item.get('return_qty', 0)), + 'final_location': item.get('return_location') + } - record = TransBorrow.query.with_for_update().get(borrow_id) + if not borrow_ids: + raise ValueError("没有有效的归还记录") + + # ========================================== + # ★ 优化步骤 2:批量锁定借用记录 + # ========================================== + borrow_records = TransBorrow.query.with_for_update().filter( + TransBorrow.id.in_(borrow_ids) + ).all() + + borrow_map = {r.id: r for r in borrow_records} + + # ========================================== + # ★ 优化步骤 3:收集库存ID并批量锁定库存 + # ========================================== + stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} + + for borrow_id, record in borrow_map.items(): + if record.source_table in stock_ids_by_table and record.stock_id: + stock_ids_by_table[record.source_table].add(record.stock_id) + + stock_map = {} # 格式: { ('stock_buy', 101): stock_obj } + for table_name, ids in stock_ids_by_table.items(): + if not ids: + continue + ModelClass = model_map[table_name] + stocks = ModelClass.query.with_for_update().filter( + ModelClass.id.in_(ids) + ).all() + for stock in stocks: + stock_map[(table_name, stock.id)] = stock + + # ========================================== + # ★ 优化步骤 4:内存中完成业务逻辑 + # ========================================== + for borrow_id, item_data in item_map.items(): + return_qty = item_data['return_qty'] + final_location = item_data['final_location'] + + record = borrow_map.get(borrow_id) if not record: continue @@ -153,22 +198,19 @@ class TransService: if return_qty > pending_qty: raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})") - ModelClass = model_map.get(record.source_table) - if ModelClass: - stock = ModelClass.query.with_for_update().get(record.stock_id) - if stock: - # 1. 恢复可用库存(按本次归还数量) - stock.available_quantity = float(stock.available_quantity) + return_qty + # 更新库存 + stock = stock_map.get((record.source_table, record.stock_id)) + if stock: + # 恢复可用库存 + stock.available_quantity = float(stock.available_quantity) + return_qty + # 更新库位 + if final_location: + stock.warehouse_location = final_location - # 2. 更新库位 (如果提供了有效值) - if final_location: - stock.warehouse_location = final_location - - # 3. 更新归还数量 + # 更新归还数量和状态 new_returned_qty = returned_qty + return_qty record.returned_quantity = new_returned_qty - # 4. 更新状态 if new_returned_qty >= total_qty: record.is_returned = True record.status = 'returned'