Files
KCGL/inventory-backend/app/api/v1/scrap.py
yueli 2556b77530 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 格式
2026-07-15 17:37:57 +08:00

423 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.transaction import TransScrap, TransRepair
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.system import SysUser
import traceback
import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
if user_role.upper() == 'SUPER_ADMIN':
return ['scrap_list:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/scrap/scan?barcode=...
# --------------------------------------------------------
@scrap_bp.route('/scan', methods=['GET'])
@jwt_required()
@permission_required('scrap_selection')
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
result = ScrapService.get_stock_by_barcode(barcode)
if result:
return jsonify({'code': 200, 'msg': '扫描成功', 'data': result})
else:
return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404
except Exception as e:
import traceback
traceback.print_exc() # 强制在控制台打印真实错误堆栈
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
# --------------------------------------------------------
# 2. 提交报废单接口
# POST /api/v1/scrap
# --------------------------------------------------------
@scrap_bp.route('', methods=['POST'])
@jwt_required()
@audit_log(
module='报废管理',
action='报废出库',
get_target_name_fn=lambda: request.get_json().get('items')[0].get('sku') if request.get_json() and request.get_json().get('items') else None
)
def create_scrap():
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return jsonify({'code': 403, 'msg': '未授权'}), 403
# 超级管理员直接放行
if user_role.upper() != 'SUPER_ADMIN':
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if 'scrap_create:operation' not in perms:
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
current_user_name = get_jwt_identity() or 'Unknown'
# items 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '报废商品列表不能为空'}), 400
try:
result = ScrapService.process_scrap(data, operator_name=current_user_name)
return jsonify({'code': 200, 'msg': '报废成功', 'data': result})
except Exception as e:
traceback.print_exc()
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
# --------------------------------------------------------
# 3. 报废记录查询接口
# GET /api/v1/scrap/records
# --------------------------------------------------------
@scrap_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('scrap_list')
def get_scrap_records():
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
sku = request.args.get('sku', '')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
try:
result = ScrapService.query_records(
page=page,
page_size=page_size,
sku=sku,
start_date=start_date,
end_date=end_date
)
return jsonify({'code': 200, 'msg': 'success', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ============================================================
# Service 层:报废核心逻辑
# ============================================================
class ScrapService:
@staticmethod
def get_stock_by_barcode(barcode):
"""根据条码查找库存"""
if not barcode:
return None
clean_code = barcode.strip()
def get_price(item, table_type):
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
return 0
# 1. 查询成品
prod = StockProduct.query.filter(
db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
res = ScrapService._format_stock(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
return res
# 2. 查询半成品
semi = StockSemi.query.filter(
db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
res = ScrapService._format_stock(semi, 'stock_semi')
res['price'] = 0
return res
# 3. 查询原材料
buy = StockBuy.query.filter(
db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
res = ScrapService._format_stock(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
return res
# 4. 查询维修单 (TransRepair)
repair = TransRepair.query.filter(
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status.notin_(['已出库', '报废转出'])
).first()
if repair:
return {
'id': repair.id,
'sku': repair.sku,
'barcode': repair.sku,
'name': repair.material_name or '维修件',
'spec': '',
'category': '',
'material_type': '',
'warehouse_loc': repair.customer_location or '',
'stock_quantity': 1,
'available_quantity': 1,
'source_table': 'trans_repair',
'price': float(repair.sale_price) if repair.sale_price else 0
}
return None
@staticmethod
def _format_stock(item, table_type):
"""格式化库存查询结果 - 使用安全 getattr 防止属性错误"""
return {
'id': getattr(item, 'id', None),
'sku': getattr(item, 'sku', ''),
'barcode': getattr(item, 'barcode', getattr(item, 'bar_code', '')),
'name': item.base.name if getattr(item, 'base', None) else '',
'spec': item.base.spec_model if getattr(item, 'base', None) else '',
'category': item.base.category if getattr(item, 'base', None) else '',
'material_type': item.base.material_type if getattr(item, 'base', None) else '',
'warehouse_loc': getattr(item, 'warehouse_location', ''),
'stock_quantity': float(getattr(item, 'stock_quantity', getattr(item, 'qty_stock', 0)) or 0),
'available_quantity': float(getattr(item, 'available_quantity', getattr(item, 'qty_available', 0)) or 0),
'source_table': table_type,
}
@staticmethod
def process_scrap(data, operator_name='System'):
"""处理报废:扣减库存并记录报废单"""
items = data.get('items', [])
reason = data.get('reason', '')
if not reason:
raise ValueError('请填写报废原因')
created_records = []
for item in items:
stock_id = item.get('id')
source_table = item.get('source_table')
scrap_qty = float(item.get('quantity', 0))
if not stock_id or not source_table or scrap_qty <= 0:
continue
# 处理维修单报废
if source_table == 'trans_repair':
repair = TransRepair.query.get(stock_id)
if not repair:
raise ValueError(f'维修单不存在: ID={stock_id}')
# 更新维修单状态为报废转出
repair.repair_status = '报废转出'
# 创建报废记录
scrap_record = TransScrap(
sku=repair.sku,
source_table='trans_repair',
stock_id=stock_id,
quantity=1,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
total_loss=float(repair.cost_price) if repair.cost_price else 0
)
db.session.add(scrap_record)
created_records.append(scrap_record)
continue
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
stock_record = None
if source_table == 'stock_product':
stock_record = StockProduct.query.with_for_update().get(stock_id)
elif source_table == 'stock_semi':
stock_record = StockSemi.query.with_for_update().get(stock_id)
elif source_table == 'stock_buy':
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}")
# 计算损失金额
unit_price = 0.0
if source_table == 'stock_product':
unit_price = float(stock_record.sale_price) if stock_record.sale_price else 0
elif source_table == 'stock_buy':
unit_price = float(stock_record.pre_tax_unit_price) if stock_record.pre_tax_unit_price else 0
total_loss = round(unit_price * scrap_qty, 2)
# 扣减库存
stock_record.stock_quantity = float(stock_record.stock_quantity) - scrap_qty
stock_record.available_quantity = float(stock_record.available_quantity) - scrap_qty
# 创建报废记录
scrap_record = TransScrap(
sku=stock_record.sku,
source_table=source_table,
stock_id=stock_id,
quantity=scrap_qty,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=unit_price,
total_loss=total_loss
)
db.session.add(scrap_record)
created_records.append(scrap_record)
db.session.commit()
return {'count': len(created_records)}
@staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
"""分页查询报废记录"""
query = TransScrap.query
if sku:
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
if start_date:
query = query.filter(TransScrap.operation_time >= start_date)
if end_date:
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表或 trans_repair 关联到 MaterialBase
company_limit = get_current_company_filter()
if company_limit is not None:
buy_subq = db.session.query(TransScrap.id).join(
StockBuy, db.and_(TransScrap.stock_id == StockBuy.id,
TransScrap.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_subq = db.session.query(TransScrap.id).join(
StockSemi, db.and_(TransScrap.stock_id == StockSemi.id,
TransScrap.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_subq = db.session.query(TransScrap.id).join(
StockProduct, db.and_(TransScrap.stock_id == StockProduct.id,
TransScrap.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
repair_subq = db.session.query(TransScrap.id).join(
TransRepair, db.and_(TransScrap.stock_id == TransRepair.id,
TransScrap.source_table == 'trans_repair')
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
all_matches = buy_subq.union(semi_subq, product_subq, repair_subq).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
# 按时间倒序
query = query.order_by(TransScrap.operation_time.desc())
total = query.count()
records = query.offset((page - 1) * page_size).limit(page_size).all()
# 遍历结果,补充操作人姓名、物料名称、规格
result_list = []
for r in records:
item = r.to_dict()
# 1. 解析操作人姓名
if r.operator_name:
# operator_name 可能是用户ID或用户名尝试解析为真实姓名
try:
# 尝试将 operator_name 当作用户ID查询
user_id = int(r.operator_name)
user = SysUser.query.get(user_id)
if user:
# 解析存储格式: "张三/zhangsan"
raw_name = user.username
if '/' in raw_name:
item['operator_name'] = raw_name.split('/')[0]
except (ValueError, TypeError):
# 如果不是数字ID保持原值
pass
# 2. 多态解析物料名称与规格
material_name = ''
spec_model = ''
if r.source_table == 'trans_repair':
# 维修单
repair = TransRepair.query.get(r.stock_id)
if repair:
material_name = repair.material_name or ''
spec_model = ''
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
# 常规库存表
stock_model = None
if r.source_table == 'stock_buy':
stock_model = StockBuy.query.get(r.stock_id)
elif r.source_table == 'stock_semi':
stock_model = StockSemi.query.get(r.stock_id)
elif r.source_table == 'stock_product':
stock_model = StockProduct.query.get(r.stock_id)
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
base = MaterialBase.query.get(stock_model.base_id)
if base:
material_name = base.name or ''
spec_model = base.spec_model or ''
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
material_name = stock_model.base.name or ''
spec_model = stock_model.base.spec_model or ''
item['material_name'] = material_name
item['spec_model'] = spec_model
result_list.append(item)
return {
'list': result_list,
'total': total,
'page': page,
'pageSize': page_size
}