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}")
|
||||
|
||||
@ -110,124 +110,162 @@ class BomService:
|
||||
return f'BOM-{timestamp}-{unique}'
|
||||
|
||||
@staticmethod
|
||||
def get_bom_list(keyword=None, active_only=False):
|
||||
def get_bom_list(keyword=None, active_only=False, category=None, page=1, limit=15):
|
||||
"""
|
||||
获取所有 BOM 配方(按 bom_no + version 分组)
|
||||
支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格
|
||||
获取所有 BOM 配方(按 bom_no + version 分组,单条 SQL 聚合 + 分页)
|
||||
|
||||
性能优化(v2):
|
||||
- 消除 N+1:单条 GROUP BY + string_agg 查询替代循环内逐条查询
|
||||
- 消除全量加载:DB 层 .paginate() 替代 .all() + Python 内存分页
|
||||
- 消除 Python 二次过滤:关键词过滤完全下沉到 SQL(子查询 + EXISTS 语义)
|
||||
|
||||
Args:
|
||||
category: 可选,按 parent_category 精确过滤(用于懒加载分组展开)
|
||||
"""
|
||||
# 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合
|
||||
query_base = db.session.query(
|
||||
child_alias = db.aliased(MaterialBase)
|
||||
|
||||
# ===== 主聚合查询(单条 SQL,GROUP BY + string_agg) =====
|
||||
query = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version
|
||||
BomTable.version,
|
||||
BomTable.parent_id,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec'),
|
||||
MaterialBase.category.label('parent_category'),
|
||||
BomTable.is_enabled,
|
||||
func.count(BomTable.child_id).label('child_count'),
|
||||
func.string_agg(child_alias.name, ', ').label('child_names'),
|
||||
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
).outerjoin(
|
||||
child_alias, BomTable.child_id == child_alias.id
|
||||
).group_by(
|
||||
BomTable.bom_no, BomTable.version, BomTable.parent_id,
|
||||
MaterialBase.name, MaterialBase.spec_model, MaterialBase.category,
|
||||
BomTable.is_enabled
|
||||
)
|
||||
|
||||
# ★ 过滤禁用状态
|
||||
# 过滤禁用状态
|
||||
if active_only:
|
||||
query_base = query_base.filter(BomTable.is_enabled == True)
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query_base = query_base.filter(MaterialBase.company_name == company_limit)
|
||||
query = query.filter(MaterialBase.company_name == company_limit)
|
||||
|
||||
# 按类别过滤(用于懒加载分组展开)
|
||||
if category:
|
||||
query = query.filter(MaterialBase.category == category)
|
||||
|
||||
# ===== 关键词过滤(完全下沉到 SQL,消除 Python 二次过滤) =====
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
# 关联子件表以支持子件搜索
|
||||
child_alias = db.aliased(MaterialBase)
|
||||
query_base = query_base.outerjoin(
|
||||
child_alias, BomTable.child_id == child_alias.id
|
||||
kw = f'%{keyword.strip()}%'
|
||||
# 子查询:找到所有匹配的 (bom_no, version) 对
|
||||
kw_child_alias = db.aliased(MaterialBase)
|
||||
match_subq = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
).outerjoin(
|
||||
kw_child_alias, BomTable.child_id == kw_child_alias.id
|
||||
).filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
child_alias.name.ilike(kw),
|
||||
child_alias.spec_model.ilike(kw)
|
||||
kw_child_alias.name.ilike(kw),
|
||||
kw_child_alias.spec_model.ilike(kw)
|
||||
)
|
||||
).distinct().subquery()
|
||||
|
||||
# 用子查询结果过滤主查询
|
||||
query = query.join(
|
||||
match_subq,
|
||||
db.and_(
|
||||
BomTable.bom_no == match_subq.c.bom_no,
|
||||
BomTable.version == match_subq.c.version
|
||||
)
|
||||
)
|
||||
|
||||
# ★ 调试:打印 SQL 语句
|
||||
logger.info(f"[BOM List] keyword={keyword!r} → SQL:\n{str(query_base.statement.compile(compile_kwargs={'literal_binds': True}))}")
|
||||
# 排序(最新在前)
|
||||
query = query.order_by(BomTable.bom_no.desc(), BomTable.version.desc())
|
||||
|
||||
# 获取符合条件的唯一组合
|
||||
target_pairs = query_base.distinct().all()
|
||||
# ===== 数据库层分页(不再 .all() 到内存) =====
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
if not target_pairs:
|
||||
return []
|
||||
|
||||
# 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称,解决步骤3过滤遗漏问题)
|
||||
results = []
|
||||
for bom_no, version in target_pairs:
|
||||
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格
|
||||
child_alias = db.aliased(MaterialBase)
|
||||
summary = db.session.query(
|
||||
BomTable.parent_id,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec'),
|
||||
MaterialBase.category.label('parent_category'),
|
||||
BomTable.is_enabled,
|
||||
func.count(BomTable.child_id).label('child_count'),
|
||||
# ★ 聚合子件名称为逗号分隔字符串(用于步骤3关键词过滤)
|
||||
func.string_agg(child_alias.name, ', ').label('child_names'),
|
||||
# ★ 同时聚合子件规格(备用)
|
||||
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
).outerjoin(
|
||||
child_alias, BomTable.child_id == child_alias.id
|
||||
).filter(
|
||||
BomTable.bom_no == bom_no,
|
||||
BomTable.version == version
|
||||
).group_by(
|
||||
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, MaterialBase.category, BomTable.is_enabled
|
||||
).first()
|
||||
|
||||
if summary:
|
||||
results.append({
|
||||
'bom_no': bom_no,
|
||||
'version': version,
|
||||
'parent_id': summary.parent_id,
|
||||
'parent_name': summary.parent_name,
|
||||
'parent_spec': summary.parent_spec or '',
|
||||
'parent_category': summary.parent_category or '',
|
||||
'is_enabled': summary.is_enabled,
|
||||
'child_count': summary.child_count,
|
||||
'child_names': summary.child_names or '', # ★ 新增:子件名称聚合
|
||||
'child_specs': summary.child_specs or '' # ★ 新增:子件规格聚合
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
|
||||
|
||||
# 如果有关键词,二次过滤结果(忽略大小写)
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
results = [
|
||||
r for r in results
|
||||
if kw in (r.get('parent_name') or '').lower()
|
||||
or kw in (r.get('parent_spec') or '').lower()
|
||||
or kw in (r.get('bom_no') or '').lower()
|
||||
or kw in (r.get('parent_category') or '').lower()
|
||||
or kw in (r.get('child_names') or '').lower() # ★ 修复:加入子件名称过滤
|
||||
or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤
|
||||
]
|
||||
|
||||
# 按 parent_category 分组
|
||||
grouped = defaultdict(list)
|
||||
for item in results:
|
||||
cat = item.get('parent_category') or '未分类'
|
||||
grouped[cat].append(item)
|
||||
|
||||
grouped_list = []
|
||||
for cat, items in sorted(grouped.items(), key=lambda x: x[0]):
|
||||
grouped_list.append({
|
||||
'category': cat,
|
||||
'count': len(items),
|
||||
'items': items
|
||||
# 组装结果
|
||||
items = []
|
||||
for row in pagination.items:
|
||||
items.append({
|
||||
'bom_no': row.bom_no,
|
||||
'version': row.version,
|
||||
'parent_id': row.parent_id,
|
||||
'parent_name': row.parent_name,
|
||||
'parent_spec': row.parent_spec or '',
|
||||
'parent_category': row.parent_category or '',
|
||||
'is_enabled': row.is_enabled,
|
||||
'child_count': row.child_count,
|
||||
'child_names': row.child_names or '',
|
||||
'child_specs': row.child_specs or ''
|
||||
})
|
||||
|
||||
return grouped_list
|
||||
return {
|
||||
'items': items,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_bom_summary(keyword=None):
|
||||
"""
|
||||
BOM 分组摘要 API(极轻量,单条 GROUP BY + COUNT)
|
||||
|
||||
SQL:
|
||||
SELECT m.category, COUNT(DISTINCT (b.bom_no, b.version)) AS count
|
||||
FROM bom_table b
|
||||
JOIN material_base m ON b.parent_id = m.id
|
||||
WHERE ...
|
||||
GROUP BY m.category
|
||||
ORDER BY m.category
|
||||
|
||||
Returns:
|
||||
[{"category": "IRIS/半成品/无人机U", "count": 15}, ...]
|
||||
"""
|
||||
query = db.session.query(
|
||||
MaterialBase.category,
|
||||
func.count(func.distinct(
|
||||
func.concat(BomTable.bom_no, '|', BomTable.version)
|
||||
)).label('count')
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
)
|
||||
|
||||
# 行级数据隔离
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(MaterialBase.company_name == company_limit)
|
||||
|
||||
# 关键词搜索
|
||||
if keyword:
|
||||
kw = f'%{keyword.strip()}%'
|
||||
query = query.filter(or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
))
|
||||
|
||||
query = query.group_by(MaterialBase.category) \
|
||||
.order_by(MaterialBase.category)
|
||||
|
||||
rows = query.all()
|
||||
return [
|
||||
{"category": r.category or "未分类", "count": r.count}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_bom_detail(bom_no, version=None):
|
||||
|
||||
@ -476,6 +476,62 @@ class MaterialBaseService:
|
||||
print(f"查询基础信息列表失败: {e}")
|
||||
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
|
||||
def get_distinct_options():
|
||||
"""
|
||||
|
||||
@ -23,20 +23,65 @@ from app.models.inbound.product import StockProduct
|
||||
class InventoryWarningService:
|
||||
|
||||
@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(
|
||||
StockBuy.base_id == base_id
|
||||
).scalar() or 0
|
||||
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
|
||||
StockSemi.base_id == base_id
|
||||
).scalar() or 0
|
||||
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
|
||||
StockProduct.base_id == base_id
|
||||
).scalar() or 0
|
||||
return float(buy_q) + float(semi_q) + float(prod_q)
|
||||
from sqlalchemy import text
|
||||
|
||||
base_ids = list({s.base_id for s in settings})
|
||||
|
||||
if not base_ids:
|
||||
return {}
|
||||
|
||||
# ── 单条 SQL:三表 UNION ALL → 外层 GROUP BY ──
|
||||
sql = text("""
|
||||
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
|
||||
def _parse_emails(email_str: str) -> list:
|
||||
@ -106,17 +151,20 @@ class InventoryWarningService:
|
||||
sent_yellow = False
|
||||
processed_settings = []
|
||||
|
||||
# ★ 性能优化:批量预取所有 setting 对应的库存 + 物料信息(1 条 SQL 替代 N*4 条)
|
||||
inv_map = InventoryWarningService._prefetch_inventory_map(settings)
|
||||
|
||||
for setting in settings:
|
||||
base_id = setting.base_id
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
mat_data = inv_map.get(base_id)
|
||||
if not mat_data:
|
||||
continue
|
||||
|
||||
name = material.name
|
||||
spec = material.spec_model or ''
|
||||
name = mat_data['name']
|
||||
spec = mat_data['spec']
|
||||
inv = mat_data['inv']
|
||||
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
|
||||
inv = InventoryWarningService._get_total_inventory(base_id)
|
||||
|
||||
# ★ 红色预警:库存 <= red_threshold,走 setting.red_emails ★
|
||||
if red_th is not None and inv <= red_th:
|
||||
|
||||
@ -254,19 +254,22 @@ class OutboundService:
|
||||
)
|
||||
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:
|
||||
approval.status = 3 # 3-已完成
|
||||
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
||||
|
||||
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -85,6 +85,9 @@ class TransService:
|
||||
borrow_no = TransService.generate_borrow_no()
|
||||
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:
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
|
||||
Reference in New Issue
Block a user