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 @@
{{ row.applicant_name || getApplicantName(row.applicant_id) }}
+
+
+
+
+ {{ row.reason_category_label }}
+
+ -
+
+
+
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
-
+
+
+
+
+ {{ row.reason_category_label }}
+
+ -
+
+
{{ row.reason || '-' }}
@@ -156,6 +186,17 @@
+
+
+
+
+ {{ row.reason_category_label }}
+
+ -
+
+
+
@@ -180,7 +221,7 @@