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