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
|
||||
|
||||
Reference in New Issue
Block a user