diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 85be817..e538836 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -3,6 +3,7 @@ from app.extensions import db, beijing_time from datetime import datetime, timedelta from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity from app.utils.decorators import permission_required +from sqlalchemy.orm import joinedload import uuid as uuid_module import io from openpyxl import Workbook @@ -954,8 +955,8 @@ def export_stocktake(): unscanned_items = [] - # 遍历 StockBuy - for stock in StockBuy.query.all(): + # ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系 + for stock in StockBuy.query.options(joinedload(StockBuy.base)).all(): key = ('stock_buy', stock.id) if key in scanned_set: continue @@ -966,7 +967,14 @@ def export_stocktake(): borrowed_qty = get_borrowed_qty('stock_buy', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: - mat_info = get_material_info('stock_buy', stock.id) + # ★ 直接使用预加载的 base 关系,避免额外查询 + material = stock.base + mat_info = { + 'name': material.name if material else '-', + 'sku': getattr(stock, 'sku', None) or '-', + 'spec': getattr(material, 'spec_model', None) if material else '-', + 'location': getattr(stock, 'warehouse_location', None) or '-' + } unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], @@ -980,7 +988,7 @@ def export_stocktake(): # 遍历 StockSemi if StockSemi: - for stock in StockSemi.query.all(): + for stock in StockSemi.query.options(joinedload(StockSemi.base)).all(): key = ('stock_semi', stock.id) if key in scanned_set: continue @@ -990,7 +998,14 @@ def export_stocktake(): borrowed_qty = get_borrowed_qty('stock_semi', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: - mat_info = get_material_info('stock_semi', stock.id) + # ★ 直接使用预加载的 base 关系,避免额外查询 + material = stock.base + mat_info = { + 'name': material.name if material else '-', + 'sku': getattr(stock, 'sku', None) or '-', + 'spec': getattr(material, 'spec_model', None) if material else '-', + 'location': getattr(stock, 'warehouse_location', None) or '-' + } unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], @@ -1004,7 +1019,7 @@ def export_stocktake(): # 遍历 StockProduct if StockProduct: - for stock in StockProduct.query.all(): + for stock in StockProduct.query.options(joinedload(StockProduct.base)).all(): key = ('stock_product', stock.id) if key in scanned_set: continue @@ -1014,7 +1029,14 @@ def export_stocktake(): borrowed_qty = get_borrowed_qty('stock_product', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: - mat_info = get_material_info('stock_product', stock.id) + # ★ 直接使用预加载的 base 关系,避免额外查询 + material = stock.base + mat_info = { + 'name': material.name if material else '-', + 'sku': getattr(stock, 'sku', None) or '-', + 'spec': getattr(material, 'spec_model', None) if material else '-', + 'location': getattr(stock, 'warehouse_location', None) or '-' + } unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], diff --git a/inventory-backend/app/api/v1/stock/adjustment.py b/inventory-backend/app/api/v1/stock/adjustment.py index 64e0e08..ad76be2 100644 --- a/inventory-backend/app/api/v1/stock/adjustment.py +++ b/inventory-backend/app/api/v1/stock/adjustment.py @@ -132,7 +132,8 @@ def create(): if not StockModel: return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400 - stock = StockModel.query.get(stock_id) + # ★ 修复并发冲突:使用悲观锁锁定行,防止多人同时修改导致的数据覆盖 + stock = StockModel.query.filter(StockModel.id == stock_id).with_for_update().first() if not stock: return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404 diff --git a/inventory-backend/app/utils/decorators.py b/inventory-backend/app/utils/decorators.py index 3878a4e..995b3fd 100644 --- a/inventory-backend/app/utils/decorators.py +++ b/inventory-backend/app/utils/decorators.py @@ -205,18 +205,18 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target username = claims.get('username', '') display_name = claims.get('display_name', '') - # 兜底:如果 display_name 为空,查询数据库获取 + # ★ 修复 DetachedInstanceError:在 fn() 执行前预先获取用户完整信息 + # 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离 if not display_name and user_id: try: from app.models.system import SysUser user = SysUser.query.get(user_id) if user: - user_info = user.to_dict() - display_name = user_info.get('display_name', username) + display_name = user.display_name or username except Exception: pass - # 获取IP + # 预先获取 IP(避免后续访问 request 对象异常) ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or '' if ip_address and ',' in ip_address: ip_address = ip_address.split(',')[0].strip() @@ -235,7 +235,7 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target raw_payload = _get_payload() filtered_payload = _filter_payload(raw_payload) if raw_payload else None - # 执行原函数 + # 执行原函数(此时 Session 可能被提交或回滚) response = fn(*args, **kwargs) # 只记录成功的请求(响应状态码 200/201) @@ -249,6 +249,9 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target from app.extensions import db from flask import current_app + # ★ 已在上方预先获取 display_name,此处无需再查询 User 对象 + # 使用预先获取的字符串数据,避免 DetachedInstanceError + # 获取 target_id target_id = None if get_target_id_fn: