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()
|
||||
@permission_required('bom_manage')
|
||||
def get_bom_list():
|
||||
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
|
||||
"""获取 BOM 列表,支持 keyword、active_only、category 过滤和分页"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '').strip()
|
||||
# 将字符串 '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()
|
||||
if isinstance(data, list):
|
||||
data = [filter_item_by_permissions(item, user_permissions) for item in data]
|
||||
if data.get('items'):
|
||||
data['items'] = [filter_item_by_permissions(item, user_permissions) for item in data['items']]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
@ -83,6 +88,23 @@ def get_bom_list():
|
||||
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'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage')
|
||||
|
||||
@ -168,6 +168,31 @@ def get_list():
|
||||
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)
|
||||
# ==============================================================================
|
||||
|
||||
@ -67,14 +67,24 @@ bp = Blueprint('stock_ops', __name__)
|
||||
# ============================================================
|
||||
# 辅助函数:获取库存记录
|
||||
# ============================================================
|
||||
def get_stock_record(source_table, stock_id):
|
||||
"""根据库存类型和ID获取库存记录"""
|
||||
def get_stock_record(source_table, stock_id, for_update=False):
|
||||
"""根据库存类型和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:
|
||||
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:
|
||||
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:
|
||||
return StockProduct.query.get(stock_id)
|
||||
q = StockProduct.query
|
||||
return (q.with_for_update() if for_update else q).get(stock_id)
|
||||
return None
|
||||
|
||||
|
||||
@ -704,8 +714,8 @@ def adjust_stock():
|
||||
return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400
|
||||
diff_qty = float(diff_qty)
|
||||
|
||||
# 3. 获取并校验真实的库存记录
|
||||
stock = get_stock_record(source_table, stock_id)
|
||||
# 3. 获取并校验真实的库存记录 — ★ 修复并发:使用悲观锁防止 TOCTOU
|
||||
stock = get_stock_record(source_table, stock_id, for_update=True)
|
||||
if not stock:
|
||||
return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404
|
||||
|
||||
@ -1061,18 +1071,18 @@ def export_stocktake():
|
||||
session_drafts = StocktakeDraft.query.all()
|
||||
scanned_set = {(d.source_table, d.stock_id) for d in session_drafts}
|
||||
|
||||
def get_borrowed_qty(source_table, stock_id):
|
||||
"""获取某库存的借出未还数量"""
|
||||
try:
|
||||
borrowed = TransBorrow.query.filter(
|
||||
TransBorrow.source_table == source_table,
|
||||
TransBorrow.stock_id == stock_id,
|
||||
TransBorrow.is_returned == False
|
||||
).all()
|
||||
total = sum(float(b.quantity or 0) - float(b.returned_quantity or 0) for b in borrowed)
|
||||
return total
|
||||
except:
|
||||
return 0
|
||||
# ★ 性能优化:批量预取所有未还借用的聚合数量
|
||||
# 单条 GROUP BY 查询替代循环内逐条 get_borrowed_qty() N+1
|
||||
borrow_rows = db.session.query(
|
||||
TransBorrow.source_table,
|
||||
TransBorrow.stock_id,
|
||||
func.sum(func.coalesce(TransBorrow.quantity, 0) - func.coalesce(TransBorrow.returned_quantity, 0)).label('pending')
|
||||
).filter(
|
||||
TransBorrow.is_returned == False
|
||||
).group_by(
|
||||
TransBorrow.source_table, TransBorrow.stock_id
|
||||
).all()
|
||||
borrow_map = {(r.source_table, r.stock_id): float(r.pending or 0) for r in borrow_rows}
|
||||
|
||||
unscanned_items = []
|
||||
|
||||
@ -1081,8 +1091,8 @@ def export_stocktake():
|
||||
key = ('stock_buy', stock.id)
|
||||
if key in scanned_set:
|
||||
continue
|
||||
# 扣除外借数量
|
||||
borrowed_qty = get_borrowed_qty('stock_buy', stock.id)
|
||||
# ★ 扣除外借数量:O(1) 字典查找替代逐条 TransBorrow 查询
|
||||
borrowed_qty = borrow_map.get(key, 0)
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
@ -1115,7 +1125,7 @@ def export_stocktake():
|
||||
key = ('stock_semi', stock.id)
|
||||
if key in scanned_set:
|
||||
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)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
@ -1151,7 +1161,7 @@ def export_stocktake():
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
if stock_qty <= 0:
|
||||
continue
|
||||
borrowed_qty = get_borrowed_qty('stock_product', stock.id)
|
||||
borrowed_qty = borrow_map.get(key, 0)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||||
|
||||
@ -261,19 +261,19 @@ class ScrapService:
|
||||
created_records.append(scrap_record)
|
||||
continue
|
||||
|
||||
# 获取库存记录
|
||||
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
|
||||
stock_record = None
|
||||
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':
|
||||
stock_record = StockSemi.query.get(stock_id)
|
||||
stock_record = StockSemi.query.with_for_update().get(stock_id)
|
||||
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:
|
||||
raise ValueError(f'库存记录不存在: ID={stock_id}')
|
||||
|
||||
# 检查可用数量
|
||||
# 检查可用数量(锁已持有,TOCTOU 窗口已消除)
|
||||
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
|
||||
if avail_qty < scrap_qty:
|
||||
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")
|
||||
|
||||
Reference in New Issue
Block a user