# 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 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') if not user_role: return [] if user_role.upper() == 'SUPER_ADMIN': return ['scrap_list:*'] perm_dict = AuthService.get_user_permissions(user_role) 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') if not user_role: return jsonify({'code': 403, 'msg': '未授权'}), 403 # 超级管理员直接放行 if user_role.upper() != 'SUPER_ADMIN': perm_dict = AuthService.get_user_permissions(user_role) 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.get(stock_id) elif source_table == 'stock_semi': stock_record = StockSemi.query.get(stock_id) elif source_table == 'stock_buy': stock_record = StockBuy.query.get(stock_id) if not stock_record: raise ValueError(f'库存记录不存在: ID={stock_id}') # 检查可用数量 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') # 按时间倒序 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 }