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:
@ -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')
|
||||||
|
|||||||
@ -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)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@ -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 关系,避免额外查询
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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, '');
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 列表失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 列表失败')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user