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:
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user