feat(scrap): 报废记录按原因分类筛选 + Excel 导出 + 显示分类
- 记录页与审批页展示「原因分类」:分类数据一直有下发,只是前端没渲染, 页面上一个地方都看不到。 - 记录页加分类筛选。★ 选「库存报废」时 SQL 一并兜住空值:本列上线前的 历史台账没有分类,而它们全是 MOM 自身流程产生的;不兜会漏掉全部历史单, 用户会以为数据丢了。 - 新增 GET /scrap/records/export 导出 xlsx:与列表同一套筛选,但导**全部 匹配结果**而不是当前页 —— 只导当前页会让人以为数据被截断。 金额按 scrap_list:loss_amount 权限决定可不可见(与列表同口径), 否则导出就成了绕过字段级权限的后门。行数上限 20000 且截断会写进文件名。
This commit is contained in:
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user