perf: 系统级性能优化与并发安全修复

## 并发安全修复 (4处)
- scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态
- stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁
- outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务
- trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险

## N+1 查询优化 (2处)
- inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次)
- stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次)

## BOM 列表性能重构
- bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询
- bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT)
- bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤

## Odoo 基础信息懒加载
- base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT)
- base.py: 新增 /api/v1/inbound/base/odoo-summary 路由
- buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems)
- material_base.ts: 新增 getOdooSummary API

## 前端 Bug 修复
- BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse)
- BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data)
- buyOdoo.vue: 移除 "点击展开加载" 文字
- Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
This commit is contained in:
yueli
2026-07-15 17:37:57 +08:00
parent 4934cd4d8f
commit 2556b77530
15 changed files with 617 additions and 233 deletions

View File

@ -62,17 +62,22 @@ def filter_item_by_permissions(item_dict, user_permissions):
@jwt_required()
@permission_required('bom_manage')
def get_bom_list():
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
"""获取 BOM 列表,支持 keywordactive_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')

View File

@ -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)
# ==============================================================================

View File

@ -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 关系,避免额外查询

View File

@ -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}")

View File

@ -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)
# ===== 主聚合查询(单条 SQLGROUP 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):

View File

@ -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():
"""

View File

@ -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:

View File

@ -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:

View File

@ -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')