diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 5453f91..3294858 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -16,6 +16,29 @@ 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 + + # ============================================================================== # 辅助函数:获取当前用户的完整权限列表(基于角色查询) # ============================================================================== @@ -135,6 +158,8 @@ def get_scrap_records(): 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') try: result = ScrapService.query_records( @@ -142,12 +167,16 @@ def get_scrap_records(): page_size=page_size, sku=sku, start_date=start_date, - end_date=end_date + end_date=end_date, + keyword=keyword, + search_type=search_type, ) - # ★ Fail-Closed: 报废记录剥离成本字段 - for item in (result.get('list') or []): - for k in ('cost_at_scrap', 'total_loss'): - item.pop(k, None) + # 损失金额按 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() @@ -333,9 +362,97 @@ class ScrapService: db.session.commit() return {'count': len(created_records)} + # ------------------------------------------------------------------ + # 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名) + # ------------------------------------------------------------------ @staticmethod - def query_records(page=1, page_size=50, sku='', start_date='', end_date=''): - """分页查询报废记录""" + 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 '', + } + + return resolved + + @staticmethod + def query_records(page=1, page_size=50, sku='', start_date='', end_date='', + keyword='', search_type='all'): + """ + 分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。 + + · 有 scrap_request_no:按单号分组; + · 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组, + 并生成可读的虚拟单号,避免历史数据成为无主记录。 + 每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。 + """ query = TransScrap.query if sku: @@ -346,6 +463,12 @@ class ScrapService: 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}%')) + # 【行级数据隔离】基于 JWT 多租户公司过滤 # 通过 stock 表或 trans_repair 关联到 MaterialBase company_limit = get_current_company_filter() @@ -403,83 +526,117 @@ class ScrapService: # 按时间倒序 query = query.order_by(TransScrap.operation_time.desc()) - total = query.count() - records = query.offset((page - 1) * page_size).limit(page_size).all() + # ================================================================== + # ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页 + # 有单号 → 按 scrap_request_no + # 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组 + # ================================================================== + rows = query.all() + mat_info = ScrapService._resolve_materials(rows) - # 遍历结果,补充操作人姓名、物料名称、规格 - 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) + groups = {} + for r in rows: + 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 '未知时间' + gkey = ('legacy', ts, r.operator_name or '') + + 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' + req_no = f"LEGACY-{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': result_list, + 'list': paged, 'total': total, 'page': page, 'pageSize': page_size diff --git a/inventory-web/src/views/operation/scrap/index.vue b/inventory-web/src/views/operation/scrap/index.vue index 24601e3..61df95b 100644 --- a/inventory-web/src/views/operation/scrap/index.vue +++ b/inventory-web/src/views/operation/scrap/index.vue @@ -1,48 +1,125 @@