perf: 系统级性能优化与并发安全修复

## 并发安全修复 (4处)
- scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态
- stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁
- outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务
- trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险

## N+1 查询优化 (2处)
- inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次)
- stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次)

## BOM 列表性能重构
- bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询
- bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT)
- bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤

## Odoo 基础信息懒加载
- base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT)
- base.py: 新增 /api/v1/inbound/base/odoo-summary 路由
- buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems)
- material_base.ts: 新增 getOdooSummary API

## 前端 Bug 修复
- BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse)
- BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data)
- buyOdoo.vue: 移除 "点击展开加载" 文字
- Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
This commit is contained in:
yueli
2026-07-15 17:37:57 +08:00
parent 4934cd4d8f
commit 2556b77530
15 changed files with 617 additions and 233 deletions

View File

@ -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 关系,避免额外查询