# inventory-backend/app/api/v1/scrap.py from flask import Blueprint, request, jsonify, current_app from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from app.utils.decorators import permission_required, get_current_company_filter from app.services.auth_service import AuthService from app.extensions import db from app.models.transaction import ( TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES, ) 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') # ============================================================================== # 报废记录字段级权限过滤(对齐 outbound 的 filter_item_by_permissions) # # 原先 /records 无条件剥离 cost_at_scrap / total_loss,导致即使角色持有 # scrap_list:loss_amount 也看不到金额——与「损失金额」权限元素的存在相矛盾。 # 改为按权限决定可见性,与出库记录保持一致。 # ============================================================================== def filter_scrap_by_permissions(item, user_permissions): field_to_perm = { 'total_loss': 'scrap_list:loss_amount', 'loss_amount': 'scrap_list:loss_amount', } if 'scrap_list:*' in user_permissions: return item for field, perm_code in field_to_perm.items(): if field in item and perm_code not in user_permissions: item[field] = None for sub in (item.get('items') or []): if isinstance(sub, dict): filter_scrap_by_permissions(sub, user_permissions) return item # ============================================================================== # 辅助函数:获取当前用户的完整权限列表(基于角色查询) # ============================================================================== 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 # ★ 可选:当前正在执行的报废申请单 id。 # 传入后,扫码命中多个来源时优先返回该单批准明细里指定的那一条。 # 必要性:在管不良品的 SKU 复制自原库存行,同一码可能同时命中 # stock_buy#M 与 trans_defective_goods#N,这是两个不同实物, # 条码本身无法区分,只有单据上下文能决定该扫到哪个。 request_id = request.args.get('request_id', type=int) prefer_pairs = None if request_id: try: from app.models.scrap_approval import ScrapApproval req = db.session.get(ScrapApproval, request_id) if req: prefer_pairs = set() for it in (req.get_items() or []): try: prefer_pairs.add(( str(it.get('source_table') or '').strip(), int(it.get('stock_id')), )) except (TypeError, ValueError): continue except Exception as e: # 优先集构造失败不应阻断扫码 —— 退回固定顺序选路,行为与改造前一致 current_app.logger.warning(f"[scrap/scan] 构造优先命中集失败: {e}") prefer_pairs = None try: result = ScrapService.get_stock_by_barcode(barcode, prefer_pairs=prefer_pairs) 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 # -------------------------------------------------------- # [已移除] 直接报废接口 POST /api/v1/scrap # # 该接口绕过审批流直接写 trans_scrap 并扣减库存,与系统自陈的规则 # 「报废一律需审批」(services/scrap_approval_service.py 的 # SCRAP_ALWAYS_REQUIRES_APPROVAL)直接冲突,且构成职责分离漏洞 —— # 同一个库管可自行宣告实物销毁而无人复核。 # # 现全部报废必须走:申请 → 审批 → 执行 # 提交 POST /api/v1/scrap/request # 审批 PATCH /api/v1/scrap/request//approve # 执行 POST /api/v1/scrap/request//execute # # 前端 api/scrap.ts 的 createScrap() 已一并删除(此前已是死代码, # 页面早已切换到申请-审批-按单执行流程)。 # # 注:权限元素 scrap_create:operation 保留在库中不删 —— # db_migrations/add_scrap_perm.sql 以它作为 scrap_apply / scrap_execute # 的授权来源,删掉会让该迁移重跑时授不出权限。 # -------------------------------------------------------- # -------------------------------------------------------- # 2.1 报废申请专用库存列表(独立权限,解耦出库选单) # GET /api/v1/scrap/stock-list # -------------------------------------------------------- @scrap_bp.route('/stock-list', methods=['GET']) @jwt_required() @permission_required('scrap_apply') def get_scrap_stock_list(): """ 报废申请专用库存列表 — Fail-Closed: 剥离价格字段 与借库 /transactions/borrow/stock-list 同构:复用 _do_get_stock_list, 但挂 scrap_apply 权限,报废申请人无需持有出库选单权限。 """ from app.api.v1.inbound.stock import _do_get_stock_list return _do_get_stock_list(permission_prefix='scrap_apply') # -------------------------------------------------------- # 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', '') keyword = request.args.get('keyword', '') search_type = request.args.get('search_type', 'all') # ★ 高级筛选:JSON 字符串 → 条件列表 from app.utils.advanced_filter import parse_advanced_filters advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', '')) try: result = ScrapService.query_records( page=page, page_size=page_size, sku=sku, start_date=start_date, end_date=end_date, keyword=keyword, search_type=search_type, advanced_filters=advanced_filters, ) # 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离, # 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾) perms = get_current_user_permissions() if 'scrap_list:*' not in perms: for item in (result.get('list') or []): filter_scrap_by_permissions(item, perms) 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, prefer_pairs=None): """ 根据条码查找库存实物。 prefer_pairs: {(source_table, row_id), ...} 可选 —— 「优先命中集」, 由调用方从**当前报废申请单的批准明细**构造。 ★ 为什么需要它(这是必须的,不是优化): 在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')),因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是**两个不同的实物**。 仅凭条码无法区分,原实现「按固定顺序返回首个命中」会让库存行 永久遮蔽不良品,导致执行时报「不在批准明细中」。 只有单据上下文能决定该扫到哪一个,故由调用方传入优先集。 返回值形状与改造前完全一致,前端无需按来源分支处理。 """ 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 # ---- 收集全部候选(不再首个即返回)---- candidates = [] 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') candidates.append(res) 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 candidates.append(res) 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') candidates.append(res) # 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载) # # ★ 本分支取代原先的「维修单」分支。维修件报废能力已随直接报废接口 # 一并移除(见文件头部 [已移除] 注释),且该来源早已被审批流的扫码 # 校验判死 —— _build_scanned_index 会拒绝 trans_* 来源。 # # ★ 物料名/规格直接取自台账的冗余快照字段,**不联表 MaterialBase** —— # 坏件的原库存行可能已被入库模块物理删除,联表会取到空值。 defective = TransDefectiveGoods.query.filter( TransDefectiveGoods.sku == clean_code ).filter( # 仅未结案的可扫:终态(已回库/已报废/已闭环)在管量已归零 TransDefectiveGoods.status.in_(OPEN_DEFECTIVE_STATUSES) ).first() if defective: # 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理 remain = float(defective.remaining_qty or 0) candidates.append({ 'id': defective.id, 'sku': defective.sku, 'barcode': defective.sku, 'name': defective.material_name or '在管不良品', 'spec': defective.spec_model or '', 'category': '', 'material_type': '', 'warehouse_loc': '', # 台账无库位字段 'stock_quantity': remain, 'available_quantity': remain, 'source_table': 'trans_defective_goods', }) if not candidates: return None # ---- 选路:单据上下文优先,否则维持改造前的固定顺序(首个命中)---- # ★ 同一 SKU 同时存在于库存表与在管台账时,条码无法区分二者; # 批准明细说该报废哪一个,就返回哪一个。 if prefer_pairs: for res in candidates: try: pair = (str(res.get('source_table') or '').strip(), int(res.get('id'))) except (TypeError, ValueError): continue if pair in prefer_pairs: return res return candidates[0] @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, } # ------------------------------------------------------------------ # 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名) # ------------------------------------------------------------------ @staticmethod def _display_name(raw): if raw is None or raw == '': return '' s = str(raw) if s.isdigit(): u = SysUser.query.get(int(s)) if u and u.username: s = u.username return s.split('/')[0] if '/' in s else s # ------------------------------------------------------------------ # 辅助:批量解析报废行的物料名称/规格/库位/批号(多态 5 条来源路径) # ------------------------------------------------------------------ @staticmethod def _resolve_materials(rows): from app.models.transaction import TransBorrow from sqlalchemy.orm import joinedload model_map = { 'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct, } resolved = {} def _from_stock(stock_row): if not stock_row: return None base = getattr(stock_row, 'base', None) return { 'material_name': (base.name if base else '') or '', 'spec_model': (base.spec_model if base else '') or '', 'warehouse_location': getattr(stock_row, 'warehouse_location', '') or '', 'batch_number': (getattr(stock_row, 'batch_number', '') or getattr(stock_row, 'serial_number', '') or ''), } # 1) 常规库存三表:批量拉取并 joinedload 物料主表 for table, model in model_map.items(): ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id} if not ids: continue for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all(): resolved[(table, obj.id)] = _from_stock(obj) # 2) 借库转报废:stock_id 指向 trans_borrow.id,再经其 source_table 找库存 borrow_ids = {r.stock_id for r in rows if r.source_table == 'trans_borrow' and r.stock_id} if borrow_ids: borrows = TransBorrow.query.filter(TransBorrow.id.in_(borrow_ids)).all() inner_ids = {} for b in borrows: if b.source_table in model_map and b.stock_id: inner_ids.setdefault(b.source_table, set()).add(b.stock_id) inner_map = {} for table, ids in inner_ids.items(): model = model_map[table] for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all(): inner_map[(table, obj.id)] = _from_stock(obj) for b in borrows: info = inner_map.get((b.source_table, b.stock_id)) if info: resolved[('trans_borrow', b.id)] = info # 3) 维修单来源:物料名在 TransRepair 上,无规格 repair_ids = {r.stock_id for r in rows if r.source_table == 'trans_repair' and r.stock_id} if repair_ids: for rp in TransRepair.query.filter(TransRepair.id.in_(repair_ids)).all(): resolved[('trans_repair', rp.id)] = { 'material_name': rp.material_name or '', 'spec_model': '', 'warehouse_location': '', 'batch_number': getattr(rp, 'serial_number', '') or '', } # 4) 不良品在管台账来源(三期):物料名与规格已冗余在台账本表,无需联表。 # ★ 之所以冗余存这几个字段:坏件的原库存行可能已被删除(入库模块会 # 物理删除库存行),联表取名称会得到空值,报表上就只剩一串 ID。 tdg_ids = {r.stock_id for r in rows if r.source_table == 'trans_defective_goods' and r.stock_id} if tdg_ids: from app.models.transaction import TransDefectiveGoods for g in TransDefectiveGoods.query.filter( TransDefectiveGoods.id.in_(tdg_ids)).all(): resolved[('trans_defective_goods', g.id)] = { 'material_name': g.material_name or '', 'spec_model': g.spec_model or '', 'warehouse_location': '', 'batch_number': '', } return resolved @staticmethod def query_records(page=1, page_size=50, sku='', start_date='', end_date='', keyword='', search_type='all', advanced_filters=None): """ 分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。 · 有 scrap_request_no:按单号分组; · 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组, 并生成可读的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。 """ 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') # 单号 / SKU 可在 SQL 层直接过滤 if keyword and search_type == 'no': query = query.filter(TransScrap.scrap_request_no.ilike(f'%{keyword}%')) elif keyword and search_type == 'sku': query = query.filter(TransScrap.sku.ilike(f'%{keyword}%')) # ==================================================================== # ★ 高级筛选 # # 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组, # 且历史数据 scrap_request_no 为 NULL,无法像出库/借还那样直接对 # 单号列做 IN。因此这里统一用**行级等价键**表达父子关系: # # 1) 先用子级条件求出「命中行」; # 2) 把命中行折算成"同单等价键"谓词; # 3) 肯定操作符 → 放行同单全部行(OR), # 否定操作符 → 整单排除(NOT ...)。 # # 等价键:有单号 → scrap_request_no 相等; # 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。 # ==================================================================== if advanced_filters: from app.utils.advanced_filter import ( build_predicate, is_negative, invert_condition, ) from sqlalchemy import or_, and_, tuple_, case, func as _func parent_map = { 'no': TransScrap.scrap_request_no, 'operator': TransScrap.operator_name, } child_map = {'sku': TransScrap.sku} def _order_key_pred(rows): """把若干命中行折算成「同单」谓词(OR 连接)""" keys = [] for h in rows: if h.scrap_request_no: keys.append(and_( TransScrap.scrap_request_no.isnot(None), TransScrap.scrap_request_no == h.scrap_request_no, )) else: # ★ 来源标记必须与内存分组键(query_records 里的 gkey) # 保持一致,否则「按单筛选」的结果与页面分组会对不上: # 同一分钟内、同一操作人的「不良品直报」与「普通直接报废」 # 在页面是两个组,在筛选里却会互相带出。 _origin_flag = case( (TransScrap.source_table == 'trans_defective_goods', 1), else_=0, ) keys.append(and_( TransScrap.scrap_request_no.is_(None), _func.date_trunc('minute', TransScrap.operation_time) == _func.date_trunc('minute', h.operation_time), TransScrap.operator_name == h.operator_name, _origin_flag == ( 1 if h.source_table == 'trans_defective_goods' else 0 ), )) return or_(*keys) if keys else None def _matched_rows(cond): """按肯定形式求出「命中该子级条件的报废行」""" probe = invert_condition(cond) field = probe.get('field') if field == 'material_name': # 物料名不在流水表:先在三张库存表求出 (source_table, stock_id) # 命中集合,再用元组 IN 定位报废行。 # ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的 # 历史行永远匹配不上,会把历史数据整体漏掉。 from app.models.base import MaterialBase name_col = MaterialBase.name name_pred = (name_col == probe.get('value') if probe.get('operator') == 'eq' else name_col.ilike(f"%{probe.get('value')}%")) pairs = [] for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'), (StockProduct, 'stock_product')]: for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id) .filter(name_pred).all()): pairs.append((sv, r.id)) if not pairs: return None return TransScrap.query.filter( tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs) ).all() p = build_predicate(probe, child_map) if p is None: return None return TransScrap.query.filter(p).all() for cond in advanced_filters: field = cond.get('field') # --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 --- if field in parent_map: p = build_predicate(cond, parent_map) if p is not None: query = query.filter(p) continue if field not in child_map and field != 'material_name': continue # --- 子级字段:正/负操作符语义分派 --- negative = is_negative(cond) rows = _matched_rows(cond) if not rows: # 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单 if not negative: query = query.filter(db.false()) continue key_pred = _order_key_pred(rows) if key_pred is None: continue query = query.filter(~key_pred if negative else key_pred) # 【行级数据隔离】基于 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) # ★ 不良品在管报废(三期):台账自带 company_name 快照,直接按它过滤。 # 刻意**不**联表 MaterialBase —— 那批坏件的原库存行可能已被删除, # 联表会让这类记录从普通用户视图中整批静默消失。 from app.models.transaction import TransDefectiveGoods defective_subq = db.session.query(TransScrap.id).join( TransDefectiveGoods, db.and_(TransScrap.stock_id == TransDefectiveGoods.id, TransScrap.source_table == 'trans_defective_goods') ).filter(TransDefectiveGoods.company_name == company_limit) all_matches = buy_subq.union( semi_subq, product_subq, repair_subq, borrow_stock_buy, borrow_stock_semi, borrow_stock_product, defective_subq ).subquery() query = query.filter(TransScrap.id.in_(all_matches)) # 按时间倒序 query = query.order_by(TransScrap.operation_time.desc()) # ================================================================== # ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页 # 有单号 → 按 scrap_request_no # 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组 # ================================================================== rows = query.all() mat_info = ScrapService._resolve_materials(rows) groups = {} for r in rows: # ★ 不良品在管报废(source_table='trans_defective_goods')同样没有审批 # 单号,但它是**新业务**产生的记录,不是历史脏数据。沿用 LEGACY- # 前缀会让业务人员误判为遗留数据,故单独成组并换用「不良品直报-」。 is_defective_origin = (r.source_table == 'trans_defective_goods') if r.scrap_request_no: gkey = ('req', r.scrap_request_no) else: ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间' # ★ 分组键带上来源标记:不良品直报自成一类,不与普通直接报废 # 混进同一组(二者前缀不同,混组会导致标题语义不一致)。 # 注意此键必须与下方 _order_key_pred 的 SQL 谓词保持一致。 gkey = ('legacy', ts, r.operator_name or '', is_defective_origin) g = groups.get(gkey) if g is None: op_name = ScrapService._display_name(r.operator_name) if gkey[0] == 'req': req_no = r.scrap_request_no else: # 虚拟单号:无审批单号的直接报废,给出可读标识便于追溯 ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000' prefix = '不良品直报' if is_defective_origin else 'LEGACY' req_no = f"{prefix}-{ts_raw}-{op_name or '未知'}" g = { 'scrap_request_no': req_no, 'is_legacy': gkey[0] == 'legacy', 'operator_name': op_name, 'applicant_id': None, 'applicant_name': '', 'scrap_time': r.operation_time.strftime('%Y-%m-%d %H:%M:%S') if r.operation_time else '', '_sort_time': r.operation_time, 'approval_status': r.approval_status or '', 'total_loss': 0.0, 'total_quantity': 0.0, 'reason': r.reason or '', 'items': [], } groups[gkey] = g info = mat_info.get((r.source_table, r.stock_id), {}) qty = float(r.quantity or 0) loss = float(r.total_loss or 0) g['total_quantity'] += qty g['total_loss'] += loss g['items'].append({ 'id': r.id, 'sku': r.sku or '', 'material_name': info.get('material_name', ''), 'spec_model': info.get('spec_model', ''), 'warehouse_location': info.get('warehouse_location', ''), 'batch_number': info.get('batch_number', ''), 'quantity': qty, 'reason': r.reason or '', 'source_table': r.source_table or '', 'loss_amount': round(loss, 2), }) orders = list(groups.values()) # ★ 补申请人:有单号的取 ScrapApproval,无单号的以操作人兜底 req_nos = [o['scrap_request_no'] for o in orders if not o['is_legacy']] if req_nos: from app.models.scrap_approval import ScrapApproval approver_cache = {} for ap in ScrapApproval.query.filter( ScrapApproval.request_no.in_(req_nos) ).all(): if ap.applicant_id not in approver_cache: # 统一走 _display_name,去掉 username 里的 '/账号' 后缀 approver_cache[ap.applicant_id] = ScrapService._display_name( ScrapApproval._user_name(ap.applicant_id) ) o = groups.get(('req', ap.request_no)) if o: o['applicant_id'] = ap.applicant_id o['applicant_name'] = approver_cache.get(ap.applicant_id, '') for o in orders: if not o['applicant_name']: o['applicant_name'] = o['operator_name'] o['total_loss'] = round(o['total_loss'], 2) o['items'].sort(key=lambda x: x['sku'] or '') # 关键词过滤:单号/SKU 已在 SQL 层处理,这里处理操作人/申请人/物料名 if keyword and search_type in ('all', 'name', 'material_name'): kw = keyword.lower() def _hit(o): if search_type == 'name': return kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower() if search_type == 'material_name': return any(kw in (it['material_name'] or '').lower() for it in o['items']) return (kw in (o['scrap_request_no'] or '').lower() or kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower() or any(kw in (it['sku'] or '').lower() or kw in (it['material_name'] or '').lower() or kw in (it['spec_model'] or '').lower() for it in o['items'])) orders = [o for o in orders if _hit(o)] orders.sort(key=lambda o: (o['_sort_time'] is None, o['_sort_time']), reverse=True) total = len(orders) start = (page - 1) * page_size paged = orders[start:start + page_size] for o in paged: o.pop('_sort_time', None) return { 'list': paged, '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(): """ 提交前预检:报废一律需审批,本接口返回 need_approval=true, 并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。 """ try: from app.services.approval_control import resolve_approval_control from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL data = request.get_json() or {} items = data.get('items', []) or [] _, flagged = resolve_approval_control(items) return jsonify({'code': 200, 'msg': 'success', 'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL, '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//withdraw', methods=['POST']) @jwt_required() def withdraw_scrap_request(request_id): """ 撤回自己的报废申请单(待审批 或 已通过但未执行)。 ★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可), 权限判定完全落在「单据归属」上 —— 服务层断言 applicant_id == 当前用户,否则 403。库管/主管可代撤。 与出库/借库的撤回语义一致:作废该单并释放其预占库存 (报废模块若未接入预占,release_reserved 会安全跳过)。 """ try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 from app.services.scrap_approval_service import ScrapApprovalService from app.utils.decorators import is_privileged_viewer # 审批页调用时调用方已有 scrap_approval 权限;此处统一走归属校验, # 特权角色可代撤(is_privileged_viewer 在服务层内判定)。 req = ScrapApprovalService.withdraw( request_id, operator_id=int(identity), require_owner=not is_privileged_viewer(), ) return jsonify({'code': 200, 'msg': '报废申请已撤回', 'data': req.to_dict()}), 200 except PermissionError as e: return jsonify({'code': 403, 'msg': str(e)}), 403 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