feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索
一、后端按「订单」分组(原为平铺明细列表) 有 scrap_request_no → 按单号分组; 无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组, 生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。 每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。 二、物料解析改为批量预加载 原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、 trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id) 收集 ID → 批量 joinedload 查询 → 内存拼装。 三、损失金额改为按权限可见 原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色 也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的 filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。 四、新增 keyword / search_type 高级搜索参数 (单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤) 五、修复申请人显示 ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经 _display_name() 去掉 '/账号' 后缀。 前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式, 搜索区改用统一 el-form inline 版式(含重置按钮)。 实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/ 虚拟单号均正确。
This commit is contained in:
@ -16,6 +16,29 @@ import math
|
||||
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 报废记录字段级权限过滤(对齐 outbound 的 filter_item_by_permissions)
|
||||
#
|
||||
# 原先 /records 无条件剥离 cost_at_scrap / total_loss,导致即使角色持有
|
||||
# scrap_list:loss_amount 也看不到金额——与「损失金额」权限元素的存在相矛盾。
|
||||
# 改为按权限决定可见性,与出库记录保持一致。
|
||||
# ==============================================================================
|
||||
def filter_scrap_by_permissions(item, user_permissions):
|
||||
field_to_perm = {
|
||||
'total_loss': 'scrap_list:loss_amount',
|
||||
'loss_amount': 'scrap_list:loss_amount',
|
||||
}
|
||||
if 'scrap_list:*' in user_permissions:
|
||||
return item
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item and perm_code not in user_permissions:
|
||||
item[field] = None
|
||||
for sub in (item.get('items') or []):
|
||||
if isinstance(sub, dict):
|
||||
filter_scrap_by_permissions(sub, user_permissions)
|
||||
return item
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
@ -135,6 +158,8 @@ def get_scrap_records():
|
||||
sku = request.args.get('sku', '')
|
||||
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')
|
||||
|
||||
try:
|
||||
result = ScrapService.query_records(
|
||||
@ -142,12 +167,16 @@ def get_scrap_records():
|
||||
page_size=page_size,
|
||||
sku=sku,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
end_date=end_date,
|
||||
keyword=keyword,
|
||||
search_type=search_type,
|
||||
)
|
||||
# ★ Fail-Closed: 报废记录剥离成本字段
|
||||
for item in (result.get('list') or []):
|
||||
for k in ('cost_at_scrap', 'total_loss'):
|
||||
item.pop(k, None)
|
||||
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
|
||||
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
|
||||
perms = get_current_user_permissions()
|
||||
if 'scrap_list:*' not in perms:
|
||||
for item in (result.get('list') or []):
|
||||
filter_scrap_by_permissions(item, perms)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -333,9 +362,97 @@ class ScrapService:
|
||||
db.session.commit()
|
||||
return {'count': len(created_records)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
|
||||
"""分页查询报废记录"""
|
||||
def _display_name(raw):
|
||||
if raw is None or raw == '':
|
||||
return ''
|
||||
s = str(raw)
|
||||
if s.isdigit():
|
||||
u = SysUser.query.get(int(s))
|
||||
if u and u.username:
|
||||
s = u.username
|
||||
return s.split('/')[0] if '/' in s else s
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助:批量解析报废行的物料名称/规格/库位/批号(多态 5 条来源路径)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _resolve_materials(rows):
|
||||
from app.models.transaction import TransBorrow
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
}
|
||||
resolved = {}
|
||||
|
||||
def _from_stock(stock_row):
|
||||
if not stock_row:
|
||||
return None
|
||||
base = getattr(stock_row, 'base', None)
|
||||
return {
|
||||
'material_name': (base.name if base else '') or '',
|
||||
'spec_model': (base.spec_model if base else '') or '',
|
||||
'warehouse_location': getattr(stock_row, 'warehouse_location', '') or '',
|
||||
'batch_number': (getattr(stock_row, 'batch_number', '')
|
||||
or getattr(stock_row, 'serial_number', '') or ''),
|
||||
}
|
||||
|
||||
# 1) 常规库存三表:批量拉取并 joinedload 物料主表
|
||||
for table, model in model_map.items():
|
||||
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
|
||||
if not ids:
|
||||
continue
|
||||
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
|
||||
resolved[(table, obj.id)] = _from_stock(obj)
|
||||
|
||||
# 2) 借库转报废:stock_id 指向 trans_borrow.id,再经其 source_table 找库存
|
||||
borrow_ids = {r.stock_id for r in rows if r.source_table == 'trans_borrow' and r.stock_id}
|
||||
if borrow_ids:
|
||||
borrows = TransBorrow.query.filter(TransBorrow.id.in_(borrow_ids)).all()
|
||||
inner_ids = {}
|
||||
for b in borrows:
|
||||
if b.source_table in model_map and b.stock_id:
|
||||
inner_ids.setdefault(b.source_table, set()).add(b.stock_id)
|
||||
inner_map = {}
|
||||
for table, ids in inner_ids.items():
|
||||
model = model_map[table]
|
||||
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
|
||||
inner_map[(table, obj.id)] = _from_stock(obj)
|
||||
for b in borrows:
|
||||
info = inner_map.get((b.source_table, b.stock_id))
|
||||
if info:
|
||||
resolved[('trans_borrow', b.id)] = info
|
||||
|
||||
# 3) 维修单来源:物料名在 TransRepair 上,无规格
|
||||
repair_ids = {r.stock_id for r in rows if r.source_table == 'trans_repair' and r.stock_id}
|
||||
if repair_ids:
|
||||
for rp in TransRepair.query.filter(TransRepair.id.in_(repair_ids)).all():
|
||||
resolved[('trans_repair', rp.id)] = {
|
||||
'material_name': rp.material_name or '',
|
||||
'spec_model': '',
|
||||
'warehouse_location': '',
|
||||
'batch_number': getattr(rp, 'serial_number', '') or '',
|
||||
}
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
|
||||
keyword='', search_type='all'):
|
||||
"""
|
||||
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
|
||||
|
||||
· 有 scrap_request_no:按单号分组;
|
||||
· 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组,
|
||||
并生成可读的虚拟单号,避免历史数据成为无主记录。
|
||||
每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。
|
||||
"""
|
||||
query = TransScrap.query
|
||||
|
||||
if sku:
|
||||
@ -346,6 +463,12 @@ class ScrapService:
|
||||
if end_date:
|
||||
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
|
||||
|
||||
# 单号 / SKU 可在 SQL 层直接过滤
|
||||
if keyword and search_type == 'no':
|
||||
query = query.filter(TransScrap.scrap_request_no.ilike(f'%{keyword}%'))
|
||||
elif keyword and search_type == 'sku':
|
||||
query = query.filter(TransScrap.sku.ilike(f'%{keyword}%'))
|
||||
|
||||
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
||||
# 通过 stock 表或 trans_repair 关联到 MaterialBase
|
||||
company_limit = get_current_company_filter()
|
||||
@ -403,83 +526,117 @@ class ScrapService:
|
||||
# 按时间倒序
|
||||
query = query.order_by(TransScrap.operation_time.desc())
|
||||
|
||||
total = query.count()
|
||||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
# ==================================================================
|
||||
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
|
||||
# 有单号 → 按 scrap_request_no
|
||||
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
|
||||
# ==================================================================
|
||||
rows = query.all()
|
||||
mat_info = ScrapService._resolve_materials(rows)
|
||||
|
||||
# 遍历结果,补充操作人姓名、物料名称、规格
|
||||
result_list = []
|
||||
for r in records:
|
||||
item = r.to_dict()
|
||||
|
||||
# 1. 解析操作人姓名
|
||||
if r.operator_name:
|
||||
# operator_name 可能是用户ID或用户名,尝试解析为真实姓名
|
||||
try:
|
||||
# 尝试将 operator_name 当作用户ID查询
|
||||
user_id = int(r.operator_name)
|
||||
user = SysUser.query.get(user_id)
|
||||
if user:
|
||||
# 解析存储格式: "张三/zhangsan"
|
||||
raw_name = user.username
|
||||
if '/' in raw_name:
|
||||
item['operator_name'] = raw_name.split('/')[0]
|
||||
except (ValueError, TypeError):
|
||||
# 如果不是数字ID,保持原值
|
||||
pass
|
||||
|
||||
# 2. 多态解析物料名称与规格
|
||||
material_name = ''
|
||||
spec_model = ''
|
||||
|
||||
if r.source_table == 'trans_borrow':
|
||||
# 借库转报废:stock_id 指向 trans_borrow.id,通过源库存表取物料
|
||||
from app.models.transaction import TransBorrow
|
||||
borrow = TransBorrow.query.get(r.stock_id)
|
||||
if borrow:
|
||||
bstock_model = None
|
||||
if borrow.source_table == 'stock_buy':
|
||||
bstock_model = StockBuy.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_semi':
|
||||
bstock_model = StockSemi.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_product':
|
||||
bstock_model = StockProduct.query.get(borrow.stock_id)
|
||||
if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id:
|
||||
base = MaterialBase.query.get(bstock_model.base_id)
|
||||
if base:
|
||||
material_name = base.name or ''
|
||||
spec_model = base.spec_model or ''
|
||||
elif r.source_table == 'trans_repair':
|
||||
# 维修单
|
||||
repair = TransRepair.query.get(r.stock_id)
|
||||
if repair:
|
||||
material_name = repair.material_name or ''
|
||||
spec_model = ''
|
||||
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
|
||||
# 常规库存表
|
||||
stock_model = None
|
||||
if r.source_table == 'stock_buy':
|
||||
stock_model = StockBuy.query.get(r.stock_id)
|
||||
elif r.source_table == 'stock_semi':
|
||||
stock_model = StockSemi.query.get(r.stock_id)
|
||||
elif r.source_table == 'stock_product':
|
||||
stock_model = StockProduct.query.get(r.stock_id)
|
||||
|
||||
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
|
||||
base = MaterialBase.query.get(stock_model.base_id)
|
||||
if base:
|
||||
material_name = base.name or ''
|
||||
spec_model = base.spec_model or ''
|
||||
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
|
||||
material_name = stock_model.base.name or ''
|
||||
spec_model = stock_model.base.spec_model or ''
|
||||
|
||||
item['material_name'] = material_name
|
||||
item['spec_model'] = spec_model
|
||||
|
||||
result_list.append(item)
|
||||
groups = {}
|
||||
for r in rows:
|
||||
if r.scrap_request_no:
|
||||
gkey = ('req', r.scrap_request_no)
|
||||
else:
|
||||
ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间'
|
||||
gkey = ('legacy', ts, r.operator_name or '')
|
||||
|
||||
g = groups.get(gkey)
|
||||
if g is None:
|
||||
op_name = ScrapService._display_name(r.operator_name)
|
||||
if gkey[0] == 'req':
|
||||
req_no = r.scrap_request_no
|
||||
else:
|
||||
# 虚拟单号:无单号的历史直接报废,仍给出可读标识便于追溯
|
||||
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
|
||||
req_no = f"LEGACY-{ts_raw}-{op_name or '未知'}"
|
||||
g = {
|
||||
'scrap_request_no': req_no,
|
||||
'is_legacy': gkey[0] == 'legacy',
|
||||
'operator_name': op_name,
|
||||
'applicant_id': None,
|
||||
'applicant_name': '',
|
||||
'scrap_time': r.operation_time.strftime('%Y-%m-%d %H:%M:%S') if r.operation_time else '',
|
||||
'_sort_time': r.operation_time,
|
||||
'approval_status': r.approval_status or '',
|
||||
'total_loss': 0.0,
|
||||
'total_quantity': 0.0,
|
||||
'reason': r.reason or '',
|
||||
'items': [],
|
||||
}
|
||||
groups[gkey] = g
|
||||
|
||||
info = mat_info.get((r.source_table, r.stock_id), {})
|
||||
qty = float(r.quantity or 0)
|
||||
loss = float(r.total_loss or 0)
|
||||
g['total_quantity'] += qty
|
||||
g['total_loss'] += loss
|
||||
g['items'].append({
|
||||
'id': r.id,
|
||||
'sku': r.sku or '',
|
||||
'material_name': info.get('material_name', ''),
|
||||
'spec_model': info.get('spec_model', ''),
|
||||
'warehouse_location': info.get('warehouse_location', ''),
|
||||
'batch_number': info.get('batch_number', ''),
|
||||
'quantity': qty,
|
||||
'reason': r.reason or '',
|
||||
'source_table': r.source_table or '',
|
||||
'loss_amount': round(loss, 2),
|
||||
})
|
||||
|
||||
orders = list(groups.values())
|
||||
|
||||
# ★ 补申请人:有单号的取 ScrapApproval,无单号的以操作人兜底
|
||||
req_nos = [o['scrap_request_no'] for o in orders if not o['is_legacy']]
|
||||
if req_nos:
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
approver_cache = {}
|
||||
for ap in ScrapApproval.query.filter(
|
||||
ScrapApproval.request_no.in_(req_nos)
|
||||
).all():
|
||||
if ap.applicant_id not in approver_cache:
|
||||
# 统一走 _display_name,去掉 username 里的 '/账号' 后缀
|
||||
approver_cache[ap.applicant_id] = ScrapService._display_name(
|
||||
ScrapApproval._user_name(ap.applicant_id)
|
||||
)
|
||||
o = groups.get(('req', ap.request_no))
|
||||
if o:
|
||||
o['applicant_id'] = ap.applicant_id
|
||||
o['applicant_name'] = approver_cache.get(ap.applicant_id, '')
|
||||
for o in orders:
|
||||
if not o['applicant_name']:
|
||||
o['applicant_name'] = o['operator_name']
|
||||
o['total_loss'] = round(o['total_loss'], 2)
|
||||
o['items'].sort(key=lambda x: x['sku'] or '')
|
||||
|
||||
# 关键词过滤:单号/SKU 已在 SQL 层处理,这里处理操作人/申请人/物料名
|
||||
if keyword and search_type in ('all', 'name', 'material_name'):
|
||||
kw = keyword.lower()
|
||||
def _hit(o):
|
||||
if search_type == 'name':
|
||||
return kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower()
|
||||
if search_type == 'material_name':
|
||||
return any(kw in (it['material_name'] or '').lower() for it in o['items'])
|
||||
return (kw in (o['scrap_request_no'] or '').lower()
|
||||
or kw in (o['operator_name'] or '').lower()
|
||||
or kw in (o['applicant_name'] or '').lower()
|
||||
or any(kw in (it['sku'] or '').lower()
|
||||
or kw in (it['material_name'] or '').lower()
|
||||
or kw in (it['spec_model'] or '').lower() for it in o['items']))
|
||||
orders = [o for o in orders if _hit(o)]
|
||||
|
||||
orders.sort(key=lambda o: (o['_sort_time'] is None, o['_sort_time']), reverse=True)
|
||||
|
||||
total = len(orders)
|
||||
start = (page - 1) * page_size
|
||||
paged = orders[start:start + page_size]
|
||||
|
||||
for o in paged:
|
||||
o.pop('_sort_time', None)
|
||||
|
||||
return {
|
||||
'list': result_list,
|
||||
'list': paged,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'pageSize': page_size
|
||||
|
||||
Reference in New Issue
Block a user