feat(scrap): 报废记录按原因分类筛选 + Excel 导出 + 显示分类

- 记录页与审批页展示「原因分类」:分类数据一直有下发,只是前端没渲染,
  页面上一个地方都看不到。
- 记录页加分类筛选。★ 选「库存报废」时 SQL 一并兜住空值:本列上线前的
  历史台账没有分类,而它们全是 MOM 自身流程产生的;不兜会漏掉全部历史单,
  用户会以为数据丢了。
- 新增 GET /scrap/records/export 导出 xlsx:与列表同一套筛选,但导**全部
  匹配结果**而不是当前页 —— 只导当前页会让人以为数据被截断。
  金额按 scrap_list:loss_amount 权限决定可不可见(与列表同口径),
  否则导出就成了绕过字段级权限的后门。行数上限 20000 且截断会写进文件名。
This commit is contained in:
yueli
2026-09-23 15:17:44 +08:00
parent c7f85880a9
commit 4c7f0ae47c
4 changed files with 308 additions and 12 deletions

View File

@ -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

View File

@ -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({

View File

@ -73,6 +73,19 @@
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</el-table-column>
<!-- 原因分类:审批人得知道批的是哪个环节的损失。
「生产损耗」= 好料领用后变坏;「库存/采购」= 在库或买来就有问题。
存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="报废种类" width="100" align="center">

View File

@ -35,9 +35,24 @@
/>
</el-form-item>
<!-- 原因分类:两种互斥口径 ——
生产报废 = **所有走 Track 的**(料领到产线后在生产中变坏);
库存报废 = **MOM 自身流程走下来的**(不看 Track、直接选库存行报废等)。
⚠️ 选「库存报废」时后端会**一并兜住空值**:本列上线前的历史台账没有
分类,而它们全都是 MOM 自身流程产生的,不兜会漏掉全部历史单 -->
<el-form-item label="原因分类">
<el-select v-model="listQuery.reason_category" clearable
placeholder="全部" style="width: 130px">
<el-option label="生产报废" value="PRODUCTION" />
<el-option label="库存报废" value="STOCK" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- 导出:与当前筛选条件一致,但导的是**全部匹配结果**而不是当前页 -->
<el-button type="success" :loading="exporting" @click="handleExport">导出 Excel</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
@ -114,6 +129,21 @@
<span v-else>-</span>
</template>
</el-table-column>
<!-- 报废原因分类:回答「这笔损失出在哪个环节」。
生产损耗(好料领用后变坏)vs 库存/采购(在库或买来就有问题)。
★ 只展示,不做任何业务判断 —— 分类由提交方**显式**写入,
不能从来源推导(Track 的生产报废与手工的不良品退回共用
同一张 trans_defective_goods 表,推导会把生产损失算成库存损失)。
⚠️ 存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column>
@ -156,6 +186,17 @@
</template>
</el-table-column>
<!-- 原因分类也放主行:不用展开就能一眼看出这笔损失出在哪个环节 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="审批状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)">
@ -180,7 +221,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { getScrapRecords } from '@/api/scrap'
import { getScrapRecords, exportScrapRecords } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
@ -197,6 +238,8 @@ const listQuery = reactive({
search_type: 'all',
dateRange: [] as string[],
advancedFilters: [] as any[],
// '' = 全部;PRODUCTION = 生产报废;STOCK = 库存报废
reason_category: '',
})
// --- ★ 高级筛选 ---
@ -255,6 +298,8 @@ const fetchData = async () => {
search_type: listQuery.search_type,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
// 原因分类:空串 = 不筛
reason_category: listQuery.reason_category || '',
}
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
@ -294,12 +339,53 @@ const resetFilter = () => {
listQuery.search_type = 'all'
listQuery.dateRange = []
listQuery.advancedFilters = []
listQuery.reason_category = ''
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
listQuery.page = 1
fetchData()
}
// ---- 导出 Excel ----
// 与列表**同一套筛选条件**,但导的是全部匹配结果(后端不限当前页)。
// 下载是浏览器的静默行为,页面不会跳转 —— 所以要给一条「导出中」的反馈,
// 否则大表导出时用户会以为按钮没反应。
const exporting = ref(false)
const handleExport = async () => {
exporting.value = true
try {
const params: any = {
keyword: listQuery.keyword,
search_type: listQuery.search_type,
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
reason_category: listQuery.reason_category || '',
}
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
params.end_date = listQuery.dateRange[1]
}
const res: any = await exportScrapRecords(params)
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const ymd = new Date().toISOString().split('T')[0]
link.download = `报废记录_${ymd}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
console.error('导出报废记录失败', e)
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
// 报废台账写在执行/提交时,approval_status 的取值为这两个
const getStatusType = (status: string) => {
const map: Record<string, string> = {