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

@ -62,17 +62,22 @@ def filter_item_by_permissions(item_dict, user_permissions):
@jwt_required() @jwt_required()
@permission_required('bom_manage') @permission_required('bom_manage')
def get_bom_list(): def get_bom_list():
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤""" """获取 BOM 列表,支持 keyword、active_only、category 过滤和分页"""
try: try:
keyword = request.args.get('keyword', '').strip() keyword = request.args.get('keyword', '').strip()
# 将字符串 'true' 转为布尔值
active_only = request.args.get('active_only', 'false').lower() == 'true' active_only = request.args.get('active_only', 'false').lower() == 'true'
category = request.args.get('category', '').strip() or None
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
data = BomService.get_bom_list(keyword=keyword, active_only=active_only) data = BomService.get_bom_list(
# 字段级脱敏 keyword=keyword, active_only=active_only,
category=category, page=page, limit=limit
)
# 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典)
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()
if isinstance(data, list): if data.get('items'):
data = [filter_item_by_permissions(item, user_permissions) for item in data] data['items'] = [filter_item_by_permissions(item, user_permissions) for item in data['items']]
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -83,6 +88,23 @@ def get_bom_list():
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==============================================================================
# BOM 分组摘要接口 (GET /api/v1/bom/summary)
# 极轻量查询:仅 GROUP BY category + COUNT(DISTINCT bom_no+version)
# ==============================================================================
@bom_bp.route('/summary', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_summary():
try:
keyword = request.args.get('keyword', '').strip() or None
data = BomService.get_bom_summary(keyword=keyword)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
current_app.logger.error(f'获取BOM摘要失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/detail/<path:bom_no>', methods=['GET']) @bom_bp.route('/detail/<path:bom_no>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage') @permission_required('bom_manage')

View File

@ -168,6 +168,31 @@ def get_list():
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 1.4 Odoo 分组摘要接口 (GET /api/v1/inbound/base/odoo-summary)
# 极轻量查询:仅 GROUP BY category + COUNT,不 JOIN 任何库存表
# ==============================================================================
@inbound_base_bp.route('/odoo-summary', methods=['GET'])
@permission_required('material_list')
def get_odoo_summary():
try:
keyword = request.args.get('keyword', '').strip() or None
is_enabled_raw = request.args.get('isEnabled', None)
is_enabled = None
if is_enabled_raw is not None:
val = str(is_enabled_raw).lower()
if val in ('1', 'true', 'yes', 't'):
is_enabled = True
elif val in ('0', 'false', 'no', 'f'):
is_enabled = False
data = MaterialBaseService.get_odoo_summary(keyword=keyword, is_enabled=is_enabled)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ============================================================================== # ==============================================================================
# 2.1 选项接口 (GET /api/v1/inbound/base/options) # 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ============================================================================== # ==============================================================================

View File

@ -67,14 +67,24 @@ bp = Blueprint('stock_ops', __name__)
# ============================================================ # ============================================================
# 辅助函数:获取库存记录 # 辅助函数:获取库存记录
# ============================================================ # ============================================================
def get_stock_record(source_table, stock_id): def get_stock_record(source_table, stock_id, for_update=False):
"""根据库存类型和ID获取库存记录""" """根据库存类型和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: 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: 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: 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 return None
@ -704,8 +714,8 @@ def adjust_stock():
return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400 return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400
diff_qty = float(diff_qty) diff_qty = float(diff_qty)
# 3. 获取并校验真实的库存记录 # 3. 获取并校验真实的库存记录 — ★ 修复并发:使用悲观锁防止 TOCTOU
stock = get_stock_record(source_table, stock_id) stock = get_stock_record(source_table, stock_id, for_update=True)
if not stock: if not stock:
return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404 return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404
@ -1061,18 +1071,18 @@ def export_stocktake():
session_drafts = StocktakeDraft.query.all() session_drafts = StocktakeDraft.query.all()
scanned_set = {(d.source_table, d.stock_id) for d in session_drafts} scanned_set = {(d.source_table, d.stock_id) for d in session_drafts}
def get_borrowed_qty(source_table, stock_id): # ★ 性能优化:批量预取所有未还借用的聚合数量
"""获取某库存的借出未还数量""" # 单条 GROUP BY 查询替代循环内逐条 get_borrowed_qty() N+1
try: borrow_rows = db.session.query(
borrowed = TransBorrow.query.filter( TransBorrow.source_table,
TransBorrow.source_table == source_table, TransBorrow.stock_id,
TransBorrow.stock_id == stock_id, func.sum(func.coalesce(TransBorrow.quantity, 0) - func.coalesce(TransBorrow.returned_quantity, 0)).label('pending')
TransBorrow.is_returned == False ).filter(
).all() TransBorrow.is_returned == False
total = sum(float(b.quantity or 0) - float(b.returned_quantity or 0) for b in borrowed) ).group_by(
return total TransBorrow.source_table, TransBorrow.stock_id
except: ).all()
return 0 borrow_map = {(r.source_table, r.stock_id): float(r.pending or 0) for r in borrow_rows}
unscanned_items = [] unscanned_items = []
@ -1081,8 +1091,8 @@ def export_stocktake():
key = ('stock_buy', stock.id) key = ('stock_buy', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
# 扣除外借数量 # ★ 扣除外借数量:O(1) 字典查找替代逐条 TransBorrow 查询
borrowed_qty = get_borrowed_qty('stock_buy', stock.id) borrowed_qty = borrow_map.get(key, 0)
stock_qty = float(stock.stock_quantity or 0) stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
@ -1115,7 +1125,7 @@ def export_stocktake():
key = ('stock_semi', stock.id) key = ('stock_semi', stock.id)
if key in scanned_set: if key in scanned_set:
continue 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) stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
@ -1151,7 +1161,7 @@ def export_stocktake():
stock_qty = float(stock.stock_quantity or 0) stock_qty = float(stock.stock_quantity or 0)
if stock_qty <= 0: if stock_qty <= 0:
continue continue
borrowed_qty = get_borrowed_qty('stock_product', stock.id) borrowed_qty = borrow_map.get(key, 0)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
# ★ 直接使用预加载的 base 关系,避免额外查询 # ★ 直接使用预加载的 base 关系,避免额外查询

View File

@ -261,19 +261,19 @@ class ScrapService:
created_records.append(scrap_record) created_records.append(scrap_record)
continue continue
# 获取库存记录 # 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
stock_record = None stock_record = None
if source_table == 'stock_product': if source_table == 'stock_product':
stock_record = StockProduct.query.get(stock_id) stock_record = StockProduct.query.with_for_update().get(stock_id)
elif source_table == 'stock_semi': elif source_table == 'stock_semi':
stock_record = StockSemi.query.get(stock_id) stock_record = StockSemi.query.with_for_update().get(stock_id)
elif source_table == 'stock_buy': elif source_table == 'stock_buy':
stock_record = StockBuy.query.get(stock_id) stock_record = StockBuy.query.with_for_update().get(stock_id)
if not stock_record: if not stock_record:
raise ValueError(f'库存记录不存在: ID={stock_id}') raise ValueError(f'库存记录不存在: ID={stock_id}')
# 检查可用数量 # 检查可用数量(锁已持有,TOCTOU 窗口已消除)
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0 avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
if avail_qty < scrap_qty: if avail_qty < scrap_qty:
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}") raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")

View File

@ -110,124 +110,162 @@ class BomService:
return f'BOM-{timestamp}-{unique}' return f'BOM-{timestamp}-{unique}'
@staticmethod @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_no + version 分组,单条 SQL 聚合 + 分页)
支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格
性能优化(v2):
- 消除 N+1:单条 GROUP BY + string_agg 查询替代循环内逐条查询
- 消除全量加载:DB 层 .paginate() 替代 .all() + Python 内存分页
- 消除 Python 二次过滤:关键词过滤完全下沉到 SQL(子查询 + EXISTS 语义)
Args:
category: 可选,按 parent_category 精确过滤(用于懒加载分组展开)
""" """
# 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合 child_alias = db.aliased(MaterialBase)
query_base = db.session.query(
# ===== 主聚合查询(单条 SQL,GROUP BY + string_agg) =====
query = db.session.query(
BomTable.bom_no, 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( ).join(
MaterialBase, BomTable.parent_id == MaterialBase.id 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: if active_only:
query_base = query_base.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
# 【行级数据隔离】基于 JWT 多租户公司过滤 # 【行级数据隔离】基于 JWT 多租户公司过滤
company_limit = get_current_company_filter() company_limit = get_current_company_filter()
if company_limit is not None: 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: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword.strip()}%'
# 关联子件表以支持子件搜索 # 子查询:找到所有匹配的 (bom_no, version) 对
child_alias = db.aliased(MaterialBase) kw_child_alias = db.aliased(MaterialBase)
query_base = query_base.outerjoin( match_subq = db.session.query(
child_alias, BomTable.child_id == child_alias.id BomTable.bom_no,
BomTable.version
).join(
MaterialBase, BomTable.parent_id == MaterialBase.id
).outerjoin(
kw_child_alias, BomTable.child_id == kw_child_alias.id
).filter( ).filter(
or_( or_(
BomTable.bom_no.ilike(kw), BomTable.bom_no.ilike(kw),
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
child_alias.name.ilike(kw), kw_child_alias.name.ilike(kw),
child_alias.spec_model.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())
# 获取符合条件的唯一组合 # ===== 数据库层分页(不再 .all() 到内存) =====
target_pairs = query_base.distinct().all() pagination = query.paginate(page=page, per_page=limit, error_out=False)
if not target_pairs: # 组装结果
return [] items = []
for row in pagination.items:
# 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称,解决步骤3过滤遗漏问题) items.append({
results = [] 'bom_no': row.bom_no,
for bom_no, version in target_pairs: 'version': row.version,
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格 'parent_id': row.parent_id,
child_alias = db.aliased(MaterialBase) 'parent_name': row.parent_name,
summary = db.session.query( 'parent_spec': row.parent_spec or '',
BomTable.parent_id, 'parent_category': row.parent_category or '',
MaterialBase.name.label('parent_name'), 'is_enabled': row.is_enabled,
MaterialBase.spec_model.label('parent_spec'), 'child_count': row.child_count,
MaterialBase.category.label('parent_category'), 'child_names': row.child_names or '',
BomTable.is_enabled, 'child_specs': row.child_specs or ''
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
}) })
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 @staticmethod
def get_bom_detail(bom_no, version=None): def get_bom_detail(bom_no, version=None):

View File

@ -476,6 +476,62 @@ class MaterialBaseService:
print(f"查询基础信息列表失败: {e}") print(f"查询基础信息列表失败: {e}")
return {"total": 0, "items": []} return {"total": 0, "items": []}
@staticmethod
def get_odoo_summary(keyword=None, is_enabled=None):
"""
Odoo 分组摘要 API(极轻量,不 JOIN 任何库存表)
执行:
SELECT category, COUNT(id) AS count
FROM material_base
WHERE ...
GROUP BY category
ORDER BY category
返回:
[{"category": "IRIS/半成品/高塔监测T", "count": 54}, ...]
"""
try:
query = db.session.query(
MaterialBase.category,
func.count(MaterialBase.id).label('count')
)
# 状态过滤
if is_enabled is not None:
query = query.filter(MaterialBase.is_enabled == is_enabled)
# 关键词搜索(与 get_list 行为一致)
if keyword:
kw = f'%{keyword.strip()}%'
query = query.filter(or_(
MaterialBase.name.ilike(kw),
MaterialBase.common_name.ilike(kw),
MaterialBase.spec_model.ilike(kw)
))
# 行级数据隔离
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# GROUP BY + ORDER
query = query.group_by(MaterialBase.category) \
.order_by(MaterialBase.category)
rows = query.all()
return [
{"category": row.category or "未分类", "count": row.count}
for row in rows
]
except Exception as e:
traceback.print_exc()
print(f"查询 Odoo 摘要失败: {e}")
return []
@staticmethod @staticmethod
def get_distinct_options(): def get_distinct_options():
""" """

View File

@ -23,20 +23,65 @@ from app.models.inbound.product import StockProduct
class InventoryWarningService: class InventoryWarningService:
@staticmethod @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( from sqlalchemy import text
StockBuy.base_id == base_id
).scalar() or 0 base_ids = list({s.base_id for s in settings})
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
StockSemi.base_id == base_id if not base_ids:
).scalar() or 0 return {}
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
StockProduct.base_id == base_id # ── 单条 SQL:三表 UNION ALL → 外层 GROUP BY ──
).scalar() or 0 sql = text("""
return float(buy_q) + float(semi_q) + float(prod_q) 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 @staticmethod
def _parse_emails(email_str: str) -> list: def _parse_emails(email_str: str) -> list:
@ -106,17 +151,20 @@ class InventoryWarningService:
sent_yellow = False sent_yellow = False
processed_settings = [] processed_settings = []
# ★ 性能优化:批量预取所有 setting 对应的库存 + 物料信息(1 条 SQL 替代 N*4 条)
inv_map = InventoryWarningService._prefetch_inventory_map(settings)
for setting in settings: for setting in settings:
base_id = setting.base_id base_id = setting.base_id
material = MaterialBase.query.get(base_id) mat_data = inv_map.get(base_id)
if not material: if not mat_data:
continue continue
name = material.name name = mat_data['name']
spec = material.spec_model or '' spec = mat_data['spec']
inv = mat_data['inv']
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None 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 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 ★ # ★ 红色预警:库存 <= red_threshold,走 setting.red_emails ★
if red_th is not None and inv <= red_th: if red_th is not None and inv <= red_th:

View File

@ -254,19 +254,22 @@ class OutboundService:
) )
db.session.add(new_record) db.session.add(new_record)
# ★ 出库后检查低库存预警
try:
from app.services.inventory_task import InventoryWarningService
InventoryWarningService.check_and_send_warning_emails()
except Exception as e:
current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}")
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成" # ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval: if approval:
approval.status = 3 # 3-已完成 approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新 # updated_at 会在 commit 时由 SQLAlchemy 自动更新
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
db.session.commit() db.session.commit()
# ★ 出库后检查低库存预警(移到 commit 之后,避免事务内网络调用)
try:
from app.services.inventory_task import InventoryWarningService
InventoryWarningService.check_and_send_warning_emails()
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"⚠️ 低库存预警检查失败: {e}")
return outbound_no return outbound_no
except Exception as e: except Exception as e:

View File

@ -85,6 +85,9 @@ class TransService:
borrow_no = TransService.generate_borrow_no() borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
# ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁
items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0)))
try: try:
for item in items: for item in items:
source_table = item.get('source_table') source_table = item.get('source_table')

View File

@ -9,6 +9,15 @@ export function getBomList(params?: any) {
}) })
} }
// 获取BOM分组摘要(懒加载用,轻量 GROUP BY category + COUNT)
export function getBomSummary(params?: { keyword?: string }) {
return request({
url: '/v1/bom/summary',
method: 'get',
params
})
}
// 获取BOM详情(含库存信息) // 获取BOM详情(含库存信息)
export function getBomWithStock(bomNo: string) { export function getBomWithStock(bomNo: string) {
const trimmed = bomNo.replace(/^\/+|\/+$/g, ''); const trimmed = bomNo.replace(/^\/+|\/+$/g, '');

View File

@ -94,4 +94,13 @@ export function getMaterialUnitsAPI() {
url: '/inbound/base/units', url: '/inbound/base/units',
method: 'get' method: 'get'
}) })
}
// 10. Odoo 分组摘要(轻量 GROUP BY category + COUNT,不 JOIN 库存表)
export function getOdooSummary(params?: { keyword?: string; isEnabled?: boolean }) {
return request({
url: '/inbound/base/odoo-summary',
method: 'get',
params
})
} }

View File

@ -17,30 +17,30 @@
<el-button :icon="Search" @click="handleSearch" /> <el-button :icon="Search" @click="handleSearch" />
</template> </template>
</el-input> </el-input>
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button> <el-button @click="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button> <el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button> <el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div> </div>
</div> </div>
</template> </template>
<el-skeleton :rows="8" animated v-if="loading && bomGroups.length === 0" /> <el-skeleton :rows="8" animated v-if="loading && groupSummary.length === 0" />
<el-empty v-else-if="!loading && bomGroups.length === 0" description="暂无 BOM 数据" /> <el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse"> <el-collapse v-else v-model="activeCategories" class="bom-category-collapse" @change="handleCollapseChange">
<el-collapse-item <el-collapse-item
v-for="group in bomGroups" v-for="group in groupedData"
:key="group.category" :key="group.category"
:title="group.category + ' (' + group.count + ')'" :title="group.category + ' (' + group.count + ')'"
:name="group.category" :name="group.category"
> >
<el-table v-if="activeCategories.includes(group.category)" :data="group.items" border style="width: 100%"> <el-table v-if="activeCategories.includes(group.category)" :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable> <el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span> <span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center"> <el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.version }}</el-tag> <el-tag>{{ row.version }}</el-tag>
@ -52,13 +52,16 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" /> <el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="200" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="160" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button> <el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button> <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div v-if="groupLoadingMap.get(group.category)" style="text-align:center;padding:12px;">
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
</div>
</el-collapse-item> </el-collapse-item>
</el-collapse> </el-collapse>
</el-card> </el-card>
@ -254,7 +257,7 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus' import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue' import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom' import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
import { searchMaterialBase } from '@/api/inbound/buy' import { searchMaterialBase } from '@/api/inbound/buy'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -314,11 +317,27 @@ let originalChildren: ChildRow[] = []
let pendingDraftBomNo = '' let pendingDraftBomNo = ''
let pendingDraftVersion = '' let pendingDraftVersion = ''
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('') const searchKeyword = ref('')
const childSearchKeyword = ref('') const childSearchKeyword = ref('')
// ★ 懒加载分组架构
interface GroupSummary { category: string; count: number }
const groupSummary = ref<GroupSummary[]>([])
const groupCache = ref<Map<string, any[]>>(new Map())
const groupLoadingMap = ref<Map<string, boolean>>(new Map())
const activeCategories = ref<string[]>([])
const lastKeyword = ref('')
const groupedData = computed(() => {
if (!groupSummary.value.length) return []
return groupSummary.value.map(s => ({
category: s.category,
count: s.count,
items: groupCache.value.get(s.category) ?? [],
loaded: groupCache.value.has(s.category)
}))
})
const filteredChildren = computed(() => { const filteredChildren = computed(() => {
if (!childSearchKeyword.value) return form.children if (!childSearchKeyword.value) return form.children
const kw = childSearchKeyword.value.toLowerCase() const kw = childSearchKeyword.value.toLowerCase()
@ -330,11 +349,10 @@ const filteredChildren = computed(() => {
}) })
// 自动搜索:输入后 500ms 防抖触发搜索(无需回车) // 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch(searchKeyword, (val) => { watch(searchKeyword, () => {
// 防抖:延迟 500ms 执行,避免频繁请求
clearTimeout((window as any)._bomSearchTimer) clearTimeout((window as any)._bomSearchTimer)
;(window as any)._bomSearchTimer = setTimeout(() => { ;(window as any)._bomSearchTimer = setTimeout(() => {
fetchBomList() fetchBomSummary()
}, 500) }, 500)
}) })
@ -465,7 +483,8 @@ const pureBomNo = computed(() => form.bom_no)
const versionOptions = computed(() => { const versionOptions = computed(() => {
const ver = originalVersion || 'V1.0' const ver = originalVersion || 'V1.0'
const allItems = bomGroups.value.flatMap((g: any) => g.items) const allItems: any[] = []
groupCache.value.forEach((items) => allItems.push(...items))
const occupiedVersions = new Set( const occupiedVersions = new Set(
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version) allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
) )
@ -500,18 +519,61 @@ const rules = reactive<FormRules>({
const dialogTitle = ref('新建 BOM') const dialogTitle = ref('新建 BOM')
const handleSearch = () => { const handleSearch = () => {
activeCategories.value = [] // 用户主动搜索时重置折叠状态 activeCategories.value = []
fetchBomList() groupCache.value = new Map()
fetchBomSummary()
} }
const fetchBomList = async () => { // ★ 挂载+搜索时调用 — 获取分组摘要
const fetchBomSummary = async () => {
loading.value = true loading.value = true
try { try {
const res = await getBomList({ keyword: searchKeyword.value }) const params: any = {}
if (res.code === 200) { if (searchKeyword.value) params.keyword = searchKeyword.value
bomGroups.value = res.data const res: any = await getBomSummary(params)
if (res?.code === 200) {
groupSummary.value = res.data ?? []
if (searchKeyword.value !== lastKeyword.value) {
activeCategories.value = []
lastKeyword.value = searchKeyword.value
}
} }
} finally { loading.value = false } } catch (e) { console.error('获取BOM摘要失败', e) }
finally { loading.value = false }
}
// ★ 默认全部展开(用户点击按钮时)
const expandAllGroups = () => {
activeCategories.value = groupSummary.value.map(g => g.category)
groupSummary.value.forEach(g => loadGroupItems(g.category))
}
const collapseAllGroups = () => { activeCategories.value = [] }
// ★ 展开分组时懒加载该分类下的 BOM
const loadGroupItems = async (category: string) => {
if (groupCache.value.has(category)) return
if (groupLoadingMap.value.get(category)) return
groupLoadingMap.value.set(category, true)
try {
const params: any = {
category: category,
page: 1,
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
}
if (searchKeyword.value) params.keyword = searchKeyword.value
const res: any = await getBomList(params)
if (res?.code === 200) {
groupCache.value.set(category, res.data?.items ?? [])
}
} catch (e) { console.error(`加载BOM分类 [${category}] 失败`, e) }
finally { groupLoadingMap.value.set(category, false) }
}
// ★ collapse @change 事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
const cats = Array.isArray(val) ? val : (val ? [val] : [])
cats.forEach(c => loadGroupItems(c))
} }
const onParentChange = (val: number) => {} const onParentChange = (val: number) => {}
@ -790,7 +852,7 @@ const handleDelete = (row: BomItem) => {
.then(async () => { .then(async () => {
try { try {
const res = await deleteBom(row.bom_no, row.version) const res = await deleteBom(row.bom_no, row.version)
if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() } if (res.code === 200) { ElMessage.success('删除成功'); groupCache.value = new Map(); fetchBomSummary() }
} catch (e) {} } catch (e) {}
}) })
.catch(() => {}) .catch(() => {})
@ -872,7 +934,8 @@ const submitForm = async () => {
localStorage.removeItem('pending_bom_draft_version') localStorage.removeItem('pending_bom_draft_version')
originalDraftHash.value = '' originalDraftHash.value = ''
dialogVisible.value = false dialogVisible.value = false
fetchBomList() groupCache.value = new Map()
fetchBomSummary()
} else { } else {
ElMessage.error(res.msg || '保存失败') ElMessage.error(res.msg || '保存失败')
} }
@ -891,22 +954,19 @@ onMounted(() => {
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料 // 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
searchKeyword.value = parentName; searchKeyword.value = parentName;
fetchBomList(); fetchBomSummary();
// 2. 延迟等待基础渲染后进行查重 // 2. 延迟等待基础渲染后进行查重
setTimeout(() => { setTimeout(() => {
getBomList({ keyword: parentName }).then((res: any) => { getBomList({ keyword: parentName, page: 1, pageSize: 50 }).then((res: any) => {
const groups = res.data || []; // ★ 适配新 API 格式:{ items: [...], total, pages }
const flatItems = res.data?.items ?? [];
let existingBom = null; let existingBom = null;
// ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id for (const bom of flatItems) {
for (const group of groups) { if (bom.parent_id === parentId) {
if (group.items && group.items.length > 0) { existingBom = bom;
const found = group.items.find((b: any) => b.parent_id === parentId); break;
if (found) {
existingBom = found;
break; // 找到了就跳出循环
}
} }
} }
@ -930,8 +990,8 @@ onMounted(() => {
}); });
}, 300); }, 300);
} else { } else {
// 如果不是从其他页面跳转过来的,直接正常加载全部列表 // 如果不是从其他页面跳转过来的,直接正常加载摘要
fetchBomList(); fetchBomSummary();
} }
}) })
</script> </script>

View File

@ -708,8 +708,18 @@ const openBomSelect = async () => {
selectedBomNo.value = '' selectedBomNo.value = ''
currentBomDetail.value = [] currentBomDetail.value = []
try { try {
const res = await getBomList({ active_only: true }) // ★ 适配新 API 格式 { items: [...], total, pages } → 前端按 parent_category 分组
bomOptions.value = res.data || [] const res = await getBomList({ active_only: true, pageSize: 9999 })
const flatItems = res.data?.items ?? []
const groupMap = new Map<string, any[]>()
for (const item of flatItems) {
const cat = item.parent_category || '未分类'
if (!groupMap.has(cat)) groupMap.set(cat, [])
groupMap.get(cat)!.push(item)
}
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 列表失败') ElMessage.error('加载 BOM 列表失败')
} }

View File

@ -204,7 +204,11 @@
</div> </div>
<div v-loading="loading" class="odoo-view-container"> <div v-loading="loading" class="odoo-view-container">
<el-collapse v-model="activeCategories" class="odoo-collapse"> <el-collapse
v-model="activeCategories"
class="odoo-collapse"
@change="handleCollapseChange"
>
<el-collapse-item <el-collapse-item
v-for="group in groupedData" v-for="group in groupedData"
:key="group.category" :key="group.category"
@ -213,7 +217,8 @@
<template #title> <template #title>
<div class="odoo-group-header"> <div class="odoo-group-header">
<span class="category-name"> <span class="category-name">
{{ group.category || '未分类' }} ({{ group.items.length }}) {{ group.category || '未分类' }} ({{ group.count }})
<el-icon v-if="groupLoadingMap.get(group.category)" class="is-loading" style="margin-left:6px;font-size:14px;"><Loading /></el-icon>
</span> </span>
</div> </div>
</template> </template>
@ -698,7 +703,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened } from '@element-plus/icons-vue'; import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened, Loading } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'; import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
@ -716,7 +721,8 @@ import {
batchSetWarning, batchSetWarning,
batchSetInspection, batchSetInspection,
markWarningOrdered, markWarningOrdered,
getMaterialUnitsAPI getMaterialUnitsAPI,
getOdooSummary
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload'; import { usePasteUpload } from '@/hooks/usePasteUpload';
@ -821,11 +827,10 @@ const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage')
const originalForm = ref<any>(null); const originalForm = ref<any>(null);
// ================= Odoo 分组核心逻辑 ================= // ================= Odoo 分组核心逻辑(懒加载架构 v2) =================
// 强行关闭分页,使用超大 pageSize
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
pageSize: 9999, pageSize: 50, // ★ 恢复正常分页大小,不再用 9999
keyword: '', keyword: '',
searchField: 'all', searchField: 'all',
category: '', category: '',
@ -838,25 +843,50 @@ const queryParams = reactive<QueryParams>({
has_stock: '' has_stock: ''
}); });
// 计算属性:前端内存分组 // ★ 新增:Odoo 分组摘要(来自 /odoo-summary API)
const groupedData = computed(() => { interface GroupSummary {
if (!tableData.value || !tableData.value.length) return []; category: string;
const groupMap = new Map<string, { category: string; items: MaterialBaseVO[] }>(); count: number;
}
const groupSummary = ref<GroupSummary[]>([]);
tableData.value.forEach(item => { // ★ 新增:分组数据缓存 Map<category, {items, total}>
const cat = item.category || '未分类'; const groupCache = ref<Map<string, { items: MaterialBaseVO[]; total: number }>>(new Map());
if (!groupMap.has(cat)) {
groupMap.set(cat, { category: cat, items: [] }); // ★ 新增:分组加载状态
} const groupLoadingMap = ref<Map<string, boolean>>(new Map());
groupMap.get(cat)!.items.push(item);
// 当前展开的分类(支持搜索全局过滤时自动全部折叠)
const lastKeyword = ref('');
// 计算属性:基于缓存构建分组数据
const groupedData = computed(() => {
if (!groupSummary.value.length) return [];
return groupSummary.value.map(summary => {
const cached = groupCache.value.get(summary.category);
return {
category: summary.category,
count: summary.count,
items: cached?.items ?? [],
total: cached?.total ?? summary.count,
loaded: !!cached
};
}); });
return Array.from(groupMap.values()).sort((a, b) => a.category.localeCompare(b.category));
}); });
// 折叠面板展开状态 // 折叠面板展开状态
const activeCategories = ref<string[]>([]); const activeCategories = ref<string[]>([]);
const expandAllGroups = () => { activeCategories.value = groupedData.value.map(g => g.category); };
const collapseAllGroups = () => { activeCategories.value = []; }; const expandAllGroups = () => {
// 展开全部 → 触发所有分组的懒加载
activeCategories.value = groupSummary.value.map(g => g.category);
groupSummary.value.forEach(g => loadGroupItems(g.category));
};
const collapseAllGroups = () => {
activeCategories.value = [];
};
// ================= 跨组表格批量选中处理 ================= // ================= 跨组表格批量选中处理 =================
const isBatchMode = ref(false); const isBatchMode = ref(false);
@ -1118,27 +1148,78 @@ const querySearchType = (queryString: string, cb: any) => {
cb(results.map(item => ({ value: item }))); cb(results.map(item => ({ value: item })));
}; };
const getList = () => { // ★ 新增:挂载时调用 - 获取分组摘要
const fetchOdooSummary = async () => {
loading.value = true; loading.value = true;
queryParams.enableWarningSort = userStore.hasPermission('material_list:view_warning') && !queryParams.orderByColumn; try {
const params = { const params: any = {};
...queryParams, if (queryParams.keyword) params.keyword = queryParams.keyword;
advancedFilters: JSON.stringify(queryParams.advancedFilters || []) if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
};
// 兼容旧版后端:company=ALL 时不传过滤参数 const res: any = await getOdooSummary(params);
if (params.company === 'ALL') { if (res?.code === 200) {
delete params.company groupSummary.value = res.data ?? [];
} // 搜索条件变更 → 清除旧缓存,折叠所有分组
listMaterialBase(params).then((response: any) => { groupCache.value = new Map();
if (response && response.data) { groupLoadingMap.value = new Map();
tableData.value = response.data.items; if (queryParams.keyword !== lastKeyword.value) {
total.value = response.data.total; activeCategories.value = [];
} else { lastKeyword.value = queryParams.keyword;
tableData.value = []; }
total.value = 0;
} }
}).catch((err) => { console.error(err); tableData.value = []; }) } catch (err) {
.finally(() => { loading.value = false; }); console.error('获取 Odoo 摘要失败', err);
} finally {
loading.value = false;
}
};
// ★ 新增:展开分组时懒加载该分类下的数据
const loadGroupItems = async (category: string) => {
// 已加载则跳过
if (groupCache.value.has(category)) return;
// 正在加载中则跳过
if (groupLoadingMap.value.get(category)) return;
groupLoadingMap.value.set(category, true);
try {
const params: any = {
pageNum: 1,
pageSize: 9999, // 单分类全量加载(分类内数据量通常可控)
category: category,
};
if (queryParams.keyword) params.keyword = queryParams.keyword;
if (queryParams.type) params.type = queryParams.type;
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
if (queryParams.company && queryParams.company !== 'ALL') params.company = queryParams.company;
const res: any = await listMaterialBase(params);
if (res?.code === 200 && res.data) {
groupCache.value.set(category, {
items: res.data.items ?? [],
total: res.data.total ?? 0
});
}
} catch (err) {
console.error(`加载分类 [${category}] 失败`, err);
} finally {
groupLoadingMap.value.set(category, false);
}
};
// ★ 监听 collapse 展开事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
// val 是当前所有展开的分类名数组
if (Array.isArray(val)) {
val.forEach(cat => loadGroupItems(cat));
} else if (val) {
loadGroupItems(val);
}
};
const getList = () => {
// Odoo 页面不再一次性全量加载,改为 fetchOdooSummary
fetchOdooSummary();
}; };
const handleExport = () => { const handleExport = () => {
@ -1476,7 +1557,7 @@ watch(
onMounted(() => { onMounted(() => {
initColumnPermissions(); initColumnPermissions();
if (!route.query.keyword) getList(); if (!route.query.keyword) fetchOdooSummary();
getOptionsList(); fetchUnitList(); getOptionsList(); fetchUnitList();
if (route.query.edit_id) { if (route.query.edit_id) {

View File

@ -741,8 +741,18 @@ const openBomSelect = async () => {
selectedBomNo.value = '' selectedBomNo.value = ''
currentBomDetail.value = [] currentBomDetail.value = []
try { try {
const res = await getBomList({ active_only: true }) // ★ 适配新 API 格式 { items: [...], total, pages } → 前端按 parent_category 分组
bomOptions.value = res.data || [] const res = await getBomList({ active_only: true, pageSize: 9999 })
const flatItems = res.data?.items ?? []
const groupMap = new Map<string, any[]>()
for (const item of flatItems) {
const cat = item.parent_category || '未分类'
if (!groupMap.has(cat)) groupMap.set(cat, [])
groupMap.get(cat)!.push(item)
}
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 列表失败') ElMessage.error('加载 BOM 列表失败')
} }