# 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: # ★ Fail-Closed: 扫码响应剥离价格字段 result.pop('price', None) 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 ) # ★ Fail-Closed: 报废记录剥离成本字段 for item in (result.get('list') or []): for k in ('cost_at_scrap', 'total_loss'): item.pop(k, None) 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 ) # ★ 借库转报废:stock_id 指向 trans_borrow.id,通过 borrow 关联源库存表再关联 MaterialBase from app.models.transaction import TransBorrow borrow_stock_buy = db.session.query(TransScrap.id).join( TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id, TransScrap.source_table == 'trans_borrow') ).join(StockBuy, TransBorrow.stock_id == StockBuy.id).join( MaterialBase, StockBuy.base_id == MaterialBase.id ).filter(MaterialBase.company_name == company_limit) borrow_stock_semi = db.session.query(TransScrap.id).join( TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id, TransScrap.source_table == 'trans_borrow') ).join(StockSemi, TransBorrow.stock_id == StockSemi.id).join( MaterialBase, StockSemi.base_id == MaterialBase.id ).filter(MaterialBase.company_name == company_limit) borrow_stock_product = db.session.query(TransScrap.id).join( TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id, TransScrap.source_table == 'trans_borrow') ).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join( MaterialBase, StockProduct.base_id == MaterialBase.id ).filter(MaterialBase.company_name == company_limit) all_matches = buy_subq.union( semi_subq, product_subq, repair_subq, borrow_stock_buy, borrow_stock_semi, borrow_stock_product ).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_borrow': # 借库转报废:stock_id 指向 trans_borrow.id,通过源库存表取物料 from app.models.transaction import TransBorrow borrow = TransBorrow.query.get(r.stock_id) if borrow: bstock_model = None if borrow.source_table == 'stock_buy': bstock_model = StockBuy.query.get(borrow.stock_id) elif borrow.source_table == 'stock_semi': bstock_model = StockSemi.query.get(borrow.stock_id) elif borrow.source_table == 'stock_product': bstock_model = StockProduct.query.get(borrow.stock_id) if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id: base = MaterialBase.query.get(bstock_model.base_id) if base: material_name = base.name or '' spec_model = base.spec_model or '' elif 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 } # ============================================================================== # 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架 # 与旧 /scrap(直接报废)并存,旧入口不动 # ============================================================================== def _current_user_role(): try: claims = get_jwt() return (claims.get('role') or '').upper() except Exception: return '' @scrap_bp.route('/request/check-approval', methods=['POST']) @jwt_required() @permission_required('scrap_apply') def scrap_check_approval(): """提交前预检:判断所选库存(物料)是否命中“需审批”""" try: from app.services.approval_control import resolve_approval_control data = request.get_json() or {} items = data.get('items', []) or [] need, flagged = resolve_approval_control(items) return jsonify({'code': 200, 'msg': 'success', 'data': {'need_approval': need, 'materials': flagged}}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500 @scrap_bp.route('/request', methods=['POST']) @jwt_required() @permission_required('scrap_apply') def create_scrap_request(): """提交报废申请(不扣库存;扣减在库管执行时)""" try: from app.services.scrap_approval_service import ScrapApprovalService identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 data = request.get_json() or {} req = ScrapApprovalService.submit_approval( applicant_id=int(identity), items=data.get('items', []), allowed_approvers=data.get('allowed_approvers'), remark=data.get('remark'), approver_id=data.get('approver_id'), force_approval=(_current_user_role() == 'WAREHOUSE_MGR'), ) return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200 except ValueError as e: return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500 @scrap_bp.route('/request', methods=['GET']) @jwt_required() @permission_required('scrap_approval') def list_scrap_requests(): """ 报废申请列表。 scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者) Query: page, limit, status(可选覆盖) """ try: from app.services.scrap_approval_service import ScrapApprovalService from app.utils.decorators import is_privileged_viewer identity = int(get_jwt_identity()) scope = (request.args.get('scope') or 'mine').lower() page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 10)) status = request.args.get('status') status = int(status) if status not in (None, '', 'all') else None priv = is_privileged_viewer() kwargs = {'page': page, 'limit': limit, 'status': status} if scope == 'pending': kwargs['status'] = 0 kwargs['approver_id'] = identity elif scope == 'executable': kwargs['status'] = 1 elif scope == 'all': if not priv: kwargs['applicant_id'] = identity else: # mine kwargs['applicant_id'] = identity data = ScrapApprovalService.get_list(**kwargs) return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500 @scrap_bp.route('/request/', methods=['GET']) @jwt_required() @permission_required('scrap_approval') def get_scrap_request_detail(request_id): try: from app.models.scrap_approval import ScrapApproval from app.utils.decorators import is_privileged_viewer req = db.session.get(ScrapApproval, request_id) if not req: return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404 if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()): # 被指定审批人也可查看 allowed = req.get_allowed_approvers() or [] if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']: return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403 return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500 @scrap_bp.route('/request//approve', methods=['PATCH']) @jwt_required() @permission_required('scrap_approval') def approve_scrap_request(request_id): """审批报废申请(仅被指定审批人)""" try: from app.services.scrap_approval_service import ScrapApprovalService data = request.get_json() or {} req = ScrapApprovalService.approve( request_id, int(get_jwt_identity()), data.get('action'), data.get('reject_reason', '') ) return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200 except ValueError as e: return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 @scrap_bp.route('/request//execute', methods=['POST']) @jwt_required() @permission_required('scrap_execute') def execute_scrap_request(request_id): """ 按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。 Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] } 实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。 """ try: from app.services.scrap_approval_service import ScrapApprovalService identity = int(get_jwt_identity()) u = SysUser.query.get(identity) data = request.get_json(silent=True) or {} req = ScrapApprovalService.execute( request_id, operator_name=(u.username if u else str(identity)), scanned_items=data.get('items') or [], ) return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200 except ValueError as e: return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500