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

@ -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):