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 # 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 flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, get_current_company_filter from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.extensions import db from app.extensions import db
from app.models.scrap_approval import scrap_category_label
from app.models.transaction import ( from app.models.transaction import (
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES, 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.base import MaterialBase
from app.models.system import SysUser from app.models.system import SysUser
import traceback import traceback
import io
from datetime import datetime
import math import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap') scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
@ -166,6 +169,8 @@ def get_scrap_records():
end_date = request.args.get('end_date', '') end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all') search_type = request.args.get('search_type', 'all')
# 原因分类筛选:'' / PRODUCTION(生产报废) / STOCK(库存报废)
reason_category = request.args.get('reason_category', '')
# ★ 高级筛选:JSON 字符串 → 条件列表 # ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters from app.utils.advanced_filter import parse_advanced_filters
@ -181,6 +186,7 @@ def get_scrap_records():
keyword=keyword, keyword=keyword,
search_type=search_type, search_type=search_type,
advanced_filters=advanced_filters, advanced_filters=advanced_filters,
reason_category=reason_category,
) )
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离, # 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾) # 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
@ -194,6 +200,110 @@ def get_scrap_records():
return jsonify({'code': 500, 'msg': str(e)}), 500 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 层:报废核心逻辑 # Service 层:报废核心逻辑
# ============================================================ # ============================================================
@ -405,20 +515,47 @@ class ScrapService:
if r.source_table == 'trans_defective_goods' and r.stock_id} if r.source_table == 'trans_defective_goods' and r.stock_id}
if tdg_ids: if tdg_ids:
from app.models.transaction import TransDefectiveGoods from app.models.transaction import TransDefectiveGoods
for g in TransDefectiveGoods.query.filter( goods_rows = TransDefectiveGoods.query.filter(
TransDefectiveGoods.id.in_(tdg_ids)).all(): 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)] = { resolved[('trans_defective_goods', g.id)] = {
'material_name': g.material_name or '', 'material_name': g.material_name or '',
'spec_model': g.spec_model or '', 'spec_model': g.spec_model or '',
'warehouse_location': '', 'warehouse_location': (g.warehouse_location
'batch_number': '', or fallback.get('warehouse_location') or ''),
'batch_number': (g.batch_number
or fallback.get('batch_number') or ''),
} }
return resolved return resolved
@staticmethod @staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date='', 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: if sku:
query = query.filter(TransScrap.sku.like(f'%{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: if start_date:
query = query.filter(TransScrap.operation_time >= start_date) query = query.filter(TransScrap.operation_time >= start_date)
if end_date: if end_date:
@ -672,6 +821,8 @@ class ScrapService:
'total_loss': 0.0, 'total_loss': 0.0,
'total_quantity': 0.0, 'total_quantity': 0.0,
'reason': r.reason or '', 'reason': r.reason or '',
'reason_category': r.reason_category or '',
'reason_category_label': scrap_category_label(r.reason_category),
'items': [], 'items': [],
} }
groups[gkey] = g groups[gkey] = g
@ -690,6 +841,8 @@ class ScrapService:
'batch_number': info.get('batch_number', ''), 'batch_number': info.get('batch_number', ''),
'quantity': qty, 'quantity': qty,
'reason': r.reason or '', '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 '', 'source_table': r.source_table or '',
'loss_amount': round(loss, 2), 'loss_amount': round(loss, 2),
}) })
@ -791,13 +944,20 @@ def scrap_check_approval():
@jwt_required() @jwt_required()
@permission_required('scrap_apply') @permission_required('scrap_apply')
def create_scrap_request(): def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)""" """提交报废申请(不扣库存;扣减在库管执行时)
审批人有两条路(都不传则回落默认审批角色,见 scrap_approval_service):
· approver_id —— 指定具体某个人
· allowed_approvers —— 角色级名单,如 [{"type":"role","value":"SUPERVISOR"}]
"""
try: try:
from app.services.scrap_approval_service import ScrapApprovalService from app.services.scrap_approval_service import ScrapApprovalService
identity = get_jwt_identity() identity = get_jwt_identity()
if not identity: if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401 return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {} data = request.get_json() or {}
# 公司快照:角色级审批据此做公司隔离(见 assert_same_company)。
# 申请人所属公司即「部门」,从 JWT 取;取不到留空(旧单口径,跳过公司校验)。
req = ScrapApprovalService.submit_approval( req = ScrapApprovalService.submit_approval(
applicant_id=int(identity), applicant_id=int(identity),
items=data.get('items', []), items=data.get('items', []),
@ -805,6 +965,8 @@ def create_scrap_request():
remark=data.get('remark'), remark=data.get('remark'),
approver_id=data.get('approver_id'), approver_id=data.get('approver_id'),
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'), 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 return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
except ValueError as e: except ValueError as e:
@ -835,10 +997,18 @@ def list_scrap_requests():
status = int(status) if status not in (None, '', 'all') else None status = int(status) if status not in (None, '', 'all') else None
priv = is_privileged_viewer() 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': if scope == 'pending':
kwargs['status'] = 0 kwargs['status'] = 0
# ★ 待办要同时按「指名的」和「我这个角色的」捞 —— 角色级审批下,
# 单据通常只写角色不写人。两者在 get_list 里是 OR 关系。
kwargs['approver_id'] = identity kwargs['approver_id'] = identity
kwargs['approver_role'] = _current_user_role()
elif scope == 'executable': elif scope == 'executable':
kwargs['status'] = 1 kwargs['status'] = 1
elif scope == 'all': elif scope == 'all':
@ -865,9 +1035,11 @@ def get_scrap_request_detail(request_id):
if not req: if not req:
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404 return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()): if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
# 被指定审批人也可查看 # 被指定审批人(或审批角色)也可查看。
allowed = req.get_allowed_approvers() or [] # ★ 走 can_operator_approve 这个单一事实来源,不要在这里再抄一份名单判定 ——
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']: # 抄出去就开始漂移,而且空名单的 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': 403, 'msg': '无权查看该报废申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200 return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e: except Exception as e:
@ -890,6 +1062,12 @@ def approve_scrap_request(request_id):
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200 return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e: except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400 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: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 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 剥离价格) // 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) { export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
return request({ return request({

View File

@ -73,6 +73,19 @@
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template> <template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</el-table-column> </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 prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="报废种类" width="100" align="center"> <el-table-column label="报废种类" width="100" align="center">

View File

@ -35,9 +35,24 @@
/> />
</el-form-item> </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-form-item>
<el-button type="primary" @click="fetchData">查询</el-button> <el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button> <el-button @click="resetFilter">重置</el-button>
<!-- 导出:与当前筛选条件一致,但导的是**全部匹配结果**而不是当前页 -->
<el-button type="success" :loading="exporting" @click="handleExport">导出 Excel</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 --> <!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover <el-popover
@ -114,6 +129,21 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </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> <el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '-' }}</template> <template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column> </el-table-column>
@ -156,6 +186,17 @@
</template> </template>
</el-table-column> </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"> <el-table-column label="审批状态" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)"> <el-tag :type="getStatusType(row.approval_status)">
@ -180,7 +221,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { getScrapRecords } from '@/api/scrap' import { getScrapRecords, exportScrapRecords } from '@/api/scrap'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()
@ -197,6 +238,8 @@ const listQuery = reactive({
search_type: 'all', search_type: 'all',
dateRange: [] as string[], dateRange: [] as string[],
advancedFilters: [] as any[], advancedFilters: [] as any[],
// '' = 全部;PRODUCTION = 生产报废;STOCK = 库存报废
reason_category: '',
}) })
// --- ★ 高级筛选 --- // --- ★ 高级筛选 ---
@ -255,6 +298,8 @@ const fetchData = async () => {
search_type: listQuery.search_type, search_type: listQuery.search_type,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串 // ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []), advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
// 原因分类:空串 = 不筛
reason_category: listQuery.reason_category || '',
} }
if (listQuery.dateRange && listQuery.dateRange.length === 2) { if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0] params.start_date = listQuery.dateRange[0]
@ -294,12 +339,53 @@ const resetFilter = () => {
listQuery.search_type = 'all' listQuery.search_type = 'all'
listQuery.dateRange = [] listQuery.dateRange = []
listQuery.advancedFilters = [] listQuery.advancedFilters = []
listQuery.reason_category = ''
advancedConditions.value = [{ field: '', operator: '', value: '' }] advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = [] appliedConditions.value = []
listQuery.page = 1 listQuery.page = 1
fetchData() 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 的取值为这两个 // 报废台账写在执行/提交时,approval_status 的取值为这两个
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {