From 4c7f0ae47c27b94b004f3047ba392374fdb68480 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 23 Sep 2026 15:17:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrap):=20=E6=8A=A5=E5=BA=9F=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=8C=89=E5=8E=9F=E5=9B=A0=E5=88=86=E7=B1=BB=E7=AD=9B?= =?UTF-8?q?=E9=80=89=20+=20Excel=20=E5=AF=BC=E5=87=BA=20+=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录页与审批页展示「原因分类」:分类数据一直有下发,只是前端没渲染, 页面上一个地方都看不到。 - 记录页加分类筛选。★ 选「库存报废」时 SQL 一并兜住空值:本列上线前的 历史台账没有分类,而它们全是 MOM 自身流程产生的;不兜会漏掉全部历史单, 用户会以为数据丢了。 - 新增 GET /scrap/records/export 导出 xlsx:与列表同一套筛选,但导**全部 匹配结果**而不是当前页 —— 只导当前页会让人以为数据被截断。 金额按 scrap_list:loss_amount 权限决定可不可见(与列表同口径), 否则导出就成了绕过字段级权限的后门。行数上限 20000 且截断会写进文件名。 --- inventory-backend/app/api/v1/scrap.py | 200 +++++++++++++++++- inventory-web/src/api/scrap.ts | 19 ++ .../views/operation/scrap/approval/index.vue | 13 ++ .../src/views/operation/scrap/index.vue | 88 +++++++- 4 files changed, 308 insertions(+), 12 deletions(-) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 955a9a8..79f1e63 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -1,9 +1,10 @@ # inventory-backend/app/api/v1/scrap.py -from flask import Blueprint, request, jsonify, current_app +from flask import Blueprint, request, jsonify, current_app, send_file 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.scrap_approval import scrap_category_label from app.models.transaction import ( TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES, ) @@ -13,6 +14,8 @@ from app.models.inbound.product import StockProduct from app.models.base import MaterialBase from app.models.system import SysUser import traceback +import io +from datetime import datetime import math scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap') @@ -166,6 +169,8 @@ def get_scrap_records(): end_date = request.args.get('end_date', '') keyword = request.args.get('keyword', '') search_type = request.args.get('search_type', 'all') + # 原因分类筛选:'' / PRODUCTION(生产报废) / STOCK(库存报废) + reason_category = request.args.get('reason_category', '') # ★ 高级筛选:JSON 字符串 → 条件列表 from app.utils.advanced_filter import parse_advanced_filters @@ -181,6 +186,7 @@ def get_scrap_records(): keyword=keyword, search_type=search_type, advanced_filters=advanced_filters, + reason_category=reason_category, ) # 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离, # 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾) @@ -194,6 +200,110 @@ def get_scrap_records(): return jsonify({'code': 500, 'msg': str(e)}), 500 +@scrap_bp.route('/records/export', methods=['GET']) +@jwt_required() +@permission_required('scrap_list') +def export_scrap_records(): + """导出报废记录为 Excel(.xlsx)。 + + 与列表页**同一套筛选条件**(关键词/日期/分类/高级筛选),但导出的是 + **全部匹配结果**而不是当前页 —— 用户点「导出」要的就是全量, + 只导当前页会让他以为数据被截断了。 + + ⚠️ 损失金额按 `scrap_list:loss_amount` 权限决定可不可见(与列表页同一口径)。 + 没权限的人导出的表里该列为空,而不是偷偷带上 —— 否则导出就成了绕过 + 字段级权限的后门。 + ⚠️ 行数上限 20000:防止有人不加筛选把整个台账导出来把内存打满。 + 截断时在表头写明「仅导出前 N 行」,**不静默截断**。 + """ + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + + 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') + sku = request.args.get('sku', '') + reason_category = request.args.get('reason_category', '') + + from app.utils.advanced_filter import parse_advanced_filters + advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', '')) + + EXPORT_LIMIT = 20000 + try: + result = ScrapService.query_records( + page=1, page_size=EXPORT_LIMIT, + sku=sku, start_date=start_date, end_date=end_date, + keyword=keyword, search_type=search_type, + advanced_filters=advanced_filters, + reason_category=reason_category, + ) + orders = result.get('list') or [] + total = result.get('total') or len(orders) + + perms = get_current_user_permissions() + can_view_loss = 'scrap_list:*' in perms + + wb = Workbook() + ws = wb.active + ws.title = '报废记录' + + headers = ['报废单号', '申请人', '操作人', '报废时间', '原因分类', + 'SKU', '物料名称', '规格型号', '库位', '批次/序列号', + '报废数量', '损失金额', '报废原因', '审批状态'] + if not can_view_loss: + headers.remove('损失金额') + ws.append(headers) + head_fill = PatternFill('solid', fgColor='DDEBF7') + for c in ws[1]: + c.font = Font(bold=True) + c.fill = head_fill + c.alignment = Alignment(horizontal='center', vertical='center') + + for o in orders: + items = o.get('items') or [{}] + for it in items: + row = [ + o.get('scrap_request_no', ''), o.get('applicant_name', ''), + o.get('operator_name', ''), o.get('scrap_time', ''), + o.get('reason_category_label', '') or '-', + it.get('sku', ''), it.get('material_name', ''), it.get('spec_model', ''), + it.get('warehouse_location', ''), it.get('batch_number', ''), + it.get('quantity', ''), it.get('loss_amount', ''), + it.get('reason', ''), o.get('approval_status', ''), + ] + if not can_view_loss: + del row[11] + ws.append(row) + + # 列宽按内容自适应(与 stock.py 的盘点导出同一做法) + for col in ws.columns: + width = 0 + letter = col[0].column_letter + for cell in col: + try: + width = max(width, len(str(cell.value))) + except Exception: + pass + ws.column_dimensions[letter].width = min(width + 2, 30) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + ts = datetime.now().strftime('%Y%m%d_%H%M%S') + note = '' if total <= EXPORT_LIMIT else f'_仅前{EXPORT_LIMIT}条(共{total})' + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'报废记录_{ts}{note}.xlsx', + ) + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500 + + # ============================================================ # Service 层:报废核心逻辑 # ============================================================ @@ -405,20 +515,47 @@ class ScrapService: 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(): + goods_rows = TransDefectiveGoods.query.filter( + TransDefectiveGoods.id.in_(tdg_ids)).all() + + # ★ 库位/批次:**快照优先,回查兜底**(口径与其他来源一致 = 原库位)。 + # + # 原实现把这两列硬编码成空串,理由是「源库存行可能已被物理删除」。 + # 该理由对 material_name/spec_model 成立(所以它们有冗余快照), + # 但库位/批次当时没有快照列,一律置空等于放弃了「源行还在」这个 + # 绝大多数情形 —— 报表上明明取得到也显示 "-"。 + # + # 现在两级:① phase14 新增的台账快照(退回时取自源行,免疫删除); + # ② 存量行没有快照,回查源库存行(与 trans_borrow 分支同一做法, + # _from_stock 本就容忍行不存在)。两级都落空才置空串。 + src_ids = {} + for g in goods_rows: + if g.source_table in model_map and g.stock_id: + src_ids.setdefault(g.source_table, set()).add(g.stock_id) + src_map = {} + for table, ids in src_ids.items(): + model = model_map[table] + for obj in (model.query.options(joinedload(model.base)) + .filter(model.id.in_(ids)).all()): + src_map[(table, obj.id)] = _from_stock(obj) + + for g in goods_rows: + fallback = src_map.get((g.source_table, g.stock_id)) or {} resolved[('trans_defective_goods', g.id)] = { 'material_name': g.material_name or '', 'spec_model': g.spec_model or '', - 'warehouse_location': '', - 'batch_number': '', + 'warehouse_location': (g.warehouse_location + or fallback.get('warehouse_location') or ''), + 'batch_number': (g.batch_number + or fallback.get('batch_number') or ''), } return resolved @staticmethod def query_records(page=1, page_size=50, sku='', start_date='', end_date='', - keyword='', search_type='all', advanced_filters=None): + keyword='', search_type='all', advanced_filters=None, + reason_category=''): """ 分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。 @@ -432,6 +569,18 @@ class ScrapService: if sku: query = query.filter(TransScrap.sku.like(f'%{sku}%')) + # ★ 原因分类筛选:两种互斥口径 —— + # 生产报废 = 所有走 Track 的;库存报废 = MOM 自身流程走下来的。 + # 'STOCK' 要同时**兜住空值**:本列上线前的历史台账没有分类,而它们 + # 全都是 MOM 自身流程产生的(Track 那条链一定会写 PRODUCTION)。 + # 不兜的话「筛库存报废」会漏掉全部历史单,用户会以为数据丢了。 + rc = (reason_category or '').strip().upper() + if rc == 'PRODUCTION': + query = query.filter(TransScrap.reason_category == 'PRODUCTION') + elif rc == 'STOCK': + query = query.filter(db.or_(TransScrap.reason_category == 'STOCK', + TransScrap.reason_category.is_(None))) + if start_date: query = query.filter(TransScrap.operation_time >= start_date) if end_date: @@ -672,6 +821,8 @@ class ScrapService: 'total_loss': 0.0, 'total_quantity': 0.0, 'reason': r.reason or '', + 'reason_category': r.reason_category or '', + 'reason_category_label': scrap_category_label(r.reason_category), 'items': [], } groups[gkey] = g @@ -690,6 +841,8 @@ class ScrapService: 'batch_number': info.get('batch_number', ''), 'quantity': qty, 'reason': r.reason or '', + 'reason_category': r.reason_category or '', + 'reason_category_label': scrap_category_label(r.reason_category), 'source_table': r.source_table or '', 'loss_amount': round(loss, 2), }) @@ -791,13 +944,20 @@ def scrap_check_approval(): @jwt_required() @permission_required('scrap_apply') def create_scrap_request(): - """提交报废申请(不扣库存;扣减在库管执行时)""" + """提交报废申请(不扣库存;扣减在库管执行时) + + 审批人有两条路(都不传则回落默认审批角色,见 scrap_approval_service): + · approver_id —— 指定具体某个人 + · allowed_approvers —— 角色级名单,如 [{"type":"role","value":"SUPERVISOR"}] + """ 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 {} + # 公司快照:角色级审批据此做公司隔离(见 assert_same_company)。 + # 申请人所属公司即「部门」,从 JWT 取;取不到留空(旧单口径,跳过公司校验)。 req = ScrapApprovalService.submit_approval( applicant_id=int(identity), items=data.get('items', []), @@ -805,6 +965,8 @@ def create_scrap_request(): remark=data.get('remark'), approver_id=data.get('approver_id'), force_approval=(_current_user_role() == 'WAREHOUSE_MGR'), + reason_category=data.get('reason_category'), + company_name=((get_jwt() or {}).get('company_name') or '').strip() or None, ) return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200 except ValueError as e: @@ -835,10 +997,18 @@ def list_scrap_requests(): status = int(status) if status not in (None, '', 'all') else None priv = is_privileged_viewer() - kwargs = {'page': page, 'limit': limit, 'status': status} + # 公司隔离:主管共 6 人(IRIS 5 / LICA 1),不按公司收敛就是跨公司可见。 + # 超管/跨域返回 None → 不过滤(与 get_current_company_filter 口径一致)。 + company_limit = get_current_company_filter() + + kwargs = {'page': page, 'limit': limit, 'status': status, + 'company_name': company_limit} if scope == 'pending': kwargs['status'] = 0 + # ★ 待办要同时按「指名的」和「我这个角色的」捞 —— 角色级审批下, + # 单据通常只写角色不写人。两者在 get_list 里是 OR 关系。 kwargs['approver_id'] = identity + kwargs['approver_role'] = _current_user_role() elif scope == 'executable': kwargs['status'] = 1 elif scope == 'all': @@ -865,9 +1035,11 @@ def get_scrap_request_detail(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']: + # 被指定审批人(或审批角色)也可查看。 + # ★ 走 can_operator_approve 这个单一事实来源,不要在这里再抄一份名单判定 —— + # 抄出去就开始漂移,而且空名单的 Fail-Closed 语义容易抄漏。 + from app.services.scrap_approval_service import ScrapApprovalService + if not ScrapApprovalService.can_operator_approve(req, int(get_jwt_identity())): return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403 return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200 except Exception as e: @@ -890,6 +1062,12 @@ def approve_scrap_request(request_id): return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200 except ValueError as e: return jsonify({'code': 400, 'msg': str(e)}), 400 + except PermissionError as e: + # ★ 必须显式捕获:PermissionError 是 OSError 的子类,**不是** ValueError。 + # 漏了这一条,「跨公司审批」会落到下面的兜底分支变成 500 —— 权限拒绝 + # 被伪装成服务端故障,排查时会往完全错误的方向找。 + # (assert_same_company 抛的就是它) + return jsonify({'code': 403, 'msg': str(e)}), 403 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 diff --git a/inventory-web/src/api/scrap.ts b/inventory-web/src/api/scrap.ts index a38ba98..339b716 100644 --- a/inventory-web/src/api/scrap.ts +++ b/inventory-web/src/api/scrap.ts @@ -29,6 +29,25 @@ export function getScrapRecords(params: any) { }) } +// 4. 报废记录导出 Excel +// +// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 —— +// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。 +// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。 +// ★ 损失金额由后端按 scrap_list:loss_amount 权限决定可不可见(与列表页同口径), +// 前端不做二次处理 —— 否则导出会变成绕过字段级权限的后门。 +export function exportScrapRecords(params: any) { + return request({ + url: '/v1/scrap/records/export', + method: 'get', + params, + responseType: 'blob' as any, + headers: { + 'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + } + }) +} + // 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格) export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) { return request({ diff --git a/inventory-web/src/views/operation/scrap/approval/index.vue b/inventory-web/src/views/operation/scrap/approval/index.vue index cf826c7..b23f41c 100644 --- a/inventory-web/src/views/operation/scrap/approval/index.vue +++ b/inventory-web/src/views/operation/scrap/approval/index.vue @@ -73,6 +73,19 @@ + + + + + diff --git a/inventory-web/src/views/operation/scrap/index.vue b/inventory-web/src/views/operation/scrap/index.vue index 18c7f04..b019d7f 100644 --- a/inventory-web/src/views/operation/scrap/index.vue +++ b/inventory-web/src/views/operation/scrap/index.vue @@ -35,9 +35,24 @@ /> + + + + + + + + 查询 重置 + + 导出 Excel - + + + + @@ -156,6 +186,17 @@ + + + + +