Files
KCGL/inventory-backend/app/api/v1/scrap.py
yueli 23a7fc65f1 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 条),申请人/损失合计/
虚拟单号均正确。
2026-09-10 12:06:04 +08:00

815 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.transaction import TransScrap, TransRepair
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.models.system import SysUser
import traceback
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
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
if user_role.upper() == 'SUPER_ADMIN':
return ['scrap_list:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/scrap/scan?barcode=...
# --------------------------------------------------------
@scrap_bp.route('/scan', methods=['GET'])
@jwt_required()
@permission_required('scrap_selection')
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
result = ScrapService.get_stock_by_barcode(barcode)
if result:
# ★ Fail-Closed: 扫码响应剥离价格字段
result.pop('price', None)
return jsonify({'code': 200, 'msg': '扫描成功', 'data': result})
else:
return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404
except Exception as e:
import traceback
traceback.print_exc() # 强制在控制台打印真实错误堆栈
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
# --------------------------------------------------------
# 2. 提交报废单接口
# POST /api/v1/scrap
# --------------------------------------------------------
@scrap_bp.route('', methods=['POST'])
@jwt_required()
@audit_log(
module='报废管理',
action='报废出库',
get_target_name_fn=lambda: request.get_json().get('items')[0].get('sku') if request.get_json() and request.get_json().get('items') else None
)
def create_scrap():
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return jsonify({'code': 403, 'msg': '未授权'}), 403
# 超级管理员直接放行
if user_role.upper() != 'SUPER_ADMIN':
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if 'scrap_create:operation' not in perms:
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
current_user_name = get_jwt_identity() or 'Unknown'
# items 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '报废商品列表不能为空'}), 400
try:
result = ScrapService.process_scrap(data, operator_name=current_user_name)
return jsonify({'code': 200, 'msg': '报废成功', 'data': result})
except Exception as e:
traceback.print_exc()
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
# --------------------------------------------------------
# 2.1 报废申请专用库存列表(独立权限,解耦出库选单)
# GET /api/v1/scrap/stock-list
# --------------------------------------------------------
@scrap_bp.route('/stock-list', methods=['GET'])
@jwt_required()
@permission_required('scrap_apply')
def get_scrap_stock_list():
"""
报废申请专用库存列表 — Fail-Closed: 剥离价格字段
与借库 /transactions/borrow/stock-list 同构:复用 _do_get_stock_list
但挂 scrap_apply 权限,报废申请人无需持有出库选单权限。
"""
from app.api.v1.inbound.stock import _do_get_stock_list
return _do_get_stock_list(permission_prefix='scrap_apply')
# --------------------------------------------------------
# 3. 报废记录查询接口
# GET /api/v1/scrap/records
# --------------------------------------------------------
@scrap_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('scrap_list')
def get_scrap_records():
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
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(
page=page,
page_size=page_size,
sku=sku,
start_date=start_date,
end_date=end_date,
keyword=keyword,
search_type=search_type,
)
# 损失金额按 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()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ============================================================
# Service 层:报废核心逻辑
# ============================================================
class ScrapService:
@staticmethod
def get_stock_by_barcode(barcode):
"""根据条码查找库存"""
if not barcode:
return None
clean_code = barcode.strip()
def get_price(item, table_type):
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
return 0
# 1. 查询成品
prod = StockProduct.query.filter(
db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
res = ScrapService._format_stock(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
return res
# 2. 查询半成品
semi = StockSemi.query.filter(
db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
res = ScrapService._format_stock(semi, 'stock_semi')
res['price'] = 0
return res
# 3. 查询原材料
buy = StockBuy.query.filter(
db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
res = ScrapService._format_stock(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
return res
# 4. 查询维修单 (TransRepair)
repair = TransRepair.query.filter(
db.or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status.notin_(['已出库', '报废转出'])
).first()
if repair:
return {
'id': repair.id,
'sku': repair.sku,
'barcode': repair.sku,
'name': repair.material_name or '维修件',
'spec': '',
'category': '',
'material_type': '',
'warehouse_loc': repair.customer_location or '',
'stock_quantity': 1,
'available_quantity': 1,
'source_table': 'trans_repair',
'price': float(repair.sale_price) if repair.sale_price else 0
}
return None
@staticmethod
def _format_stock(item, table_type):
"""格式化库存查询结果 - 使用安全 getattr 防止属性错误"""
return {
'id': getattr(item, 'id', None),
'sku': getattr(item, 'sku', ''),
'barcode': getattr(item, 'barcode', getattr(item, 'bar_code', '')),
'name': item.base.name if getattr(item, 'base', None) else '',
'spec': item.base.spec_model if getattr(item, 'base', None) else '',
'category': item.base.category if getattr(item, 'base', None) else '',
'material_type': item.base.material_type if getattr(item, 'base', None) else '',
'warehouse_loc': getattr(item, 'warehouse_location', ''),
'stock_quantity': float(getattr(item, 'stock_quantity', getattr(item, 'qty_stock', 0)) or 0),
'available_quantity': float(getattr(item, 'available_quantity', getattr(item, 'qty_available', 0)) or 0),
'source_table': table_type,
}
@staticmethod
def process_scrap(data, operator_name='System'):
"""处理报废:扣减库存并记录报废单"""
items = data.get('items', [])
reason = data.get('reason', '')
if not reason:
raise ValueError('请填写报废原因')
created_records = []
for item in items:
stock_id = item.get('id')
source_table = item.get('source_table')
scrap_qty = float(item.get('quantity', 0))
if not stock_id or not source_table or scrap_qty <= 0:
continue
# 处理维修单报废
if source_table == 'trans_repair':
repair = TransRepair.query.get(stock_id)
if not repair:
raise ValueError(f'维修单不存在: ID={stock_id}')
# 更新维修单状态为报废转出
repair.repair_status = '报废转出'
# 创建报废记录
scrap_record = TransScrap(
sku=repair.sku,
source_table='trans_repair',
stock_id=stock_id,
quantity=1,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=float(repair.cost_price) if repair.cost_price else 0,
total_loss=float(repair.cost_price) if repair.cost_price else 0
)
db.session.add(scrap_record)
created_records.append(scrap_record)
continue
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
stock_record = None
if source_table == 'stock_product':
stock_record = StockProduct.query.with_for_update().get(stock_id)
elif source_table == 'stock_semi':
stock_record = StockSemi.query.with_for_update().get(stock_id)
elif source_table == 'stock_buy':
stock_record = StockBuy.query.with_for_update().get(stock_id)
if not stock_record:
raise ValueError(f'库存记录不存在: ID={stock_id}')
# 检查可用数量锁已持有TOCTOU 窗口已消除)
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
if avail_qty < scrap_qty:
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")
# 计算损失金额
unit_price = 0.0
if source_table == 'stock_product':
unit_price = float(stock_record.sale_price) if stock_record.sale_price else 0
elif source_table == 'stock_buy':
unit_price = float(stock_record.pre_tax_unit_price) if stock_record.pre_tax_unit_price else 0
total_loss = round(unit_price * scrap_qty, 2)
# 扣减库存
stock_record.stock_quantity = float(stock_record.stock_quantity) - scrap_qty
stock_record.available_quantity = float(stock_record.available_quantity) - scrap_qty
# 创建报废记录
scrap_record = TransScrap(
sku=stock_record.sku,
source_table=source_table,
stock_id=stock_id,
quantity=scrap_qty,
reason=reason,
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=unit_price,
total_loss=total_loss
)
db.session.add(scrap_record)
created_records.append(scrap_record)
db.session.commit()
return {'count': len(created_records)}
# ------------------------------------------------------------------
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
# ------------------------------------------------------------------
@staticmethod
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:
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
if start_date:
query = query.filter(TransScrap.operation_time >= start_date)
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()
if company_limit is not None:
buy_subq = db.session.query(TransScrap.id).join(
StockBuy, db.and_(TransScrap.stock_id == StockBuy.id,
TransScrap.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_subq = db.session.query(TransScrap.id).join(
StockSemi, db.and_(TransScrap.stock_id == StockSemi.id,
TransScrap.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_subq = db.session.query(TransScrap.id).join(
StockProduct, db.and_(TransScrap.stock_id == StockProduct.id,
TransScrap.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
repair_subq = db.session.query(TransScrap.id).join(
TransRepair, db.and_(TransScrap.stock_id == TransRepair.id,
TransScrap.source_table == 'trans_repair')
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
# ★ 借库转报废stock_id 指向 trans_borrow.id通过 borrow 关联源库存表再关联 MaterialBase
from app.models.transaction import TransBorrow
borrow_stock_buy = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockBuy, TransBorrow.stock_id == StockBuy.id).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_semi = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockSemi, TransBorrow.stock_id == StockSemi.id).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_product = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
all_matches = buy_subq.union(
semi_subq, product_subq, repair_subq,
borrow_stock_buy, borrow_stock_semi, borrow_stock_product
).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
# 按时间倒序
query = query.order_by(TransScrap.operation_time.desc())
# ==================================================================
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
# 有单号 → 按 scrap_request_no
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
# ==================================================================
rows = query.all()
mat_info = ScrapService._resolve_materials(rows)
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': paged,
'total': total,
'page': page,
'pageSize': page_size
}
# ==============================================================================
# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架
# 与旧 /scrap直接报废并存旧入口不动
# ==============================================================================
def _current_user_role():
try:
claims = get_jwt()
return (claims.get('role') or '').upper()
except Exception:
return ''
@scrap_bp.route('/request/check-approval', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def scrap_check_approval():
"""
提交前预检:报废一律需审批,本接口返回 need_approval=true
并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。
"""
try:
from app.services.approval_control import resolve_approval_control
from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL
data = request.get_json() or {}
items = data.get('items', []) or []
_, flagged = resolve_approval_control(items)
return jsonify({'code': 200, 'msg': 'success',
'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL,
'materials': flagged}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['POST'])
@jwt_required()
@permission_required('scrap_apply')
def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)"""
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 {}
req = ScrapApprovalService.submit_approval(
applicant_id=int(identity),
items=data.get('items', []),
allowed_approvers=data.get('allowed_approvers'),
remark=data.get('remark'),
approver_id=data.get('approver_id'),
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
)
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def list_scrap_requests():
"""
报废申请列表。
scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者)
Query: page, limit, status(可选覆盖)
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
from app.utils.decorators import is_privileged_viewer
identity = int(get_jwt_identity())
scope = (request.args.get('scope') or 'mine').lower()
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
status = request.args.get('status')
status = int(status) if status not in (None, '', 'all') else None
priv = is_privileged_viewer()
kwargs = {'page': page, 'limit': limit, 'status': status}
if scope == 'pending':
kwargs['status'] = 0
kwargs['approver_id'] = identity
elif scope == 'executable':
kwargs['status'] = 1
elif scope == 'all':
if not priv:
kwargs['applicant_id'] = identity
else: # mine
kwargs['applicant_id'] = identity
data = ScrapApprovalService.get_list(**kwargs)
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
@permission_required('scrap_approval')
def get_scrap_request_detail(request_id):
try:
from app.models.scrap_approval import ScrapApproval
from app.utils.decorators import is_privileged_viewer
req = db.session.get(ScrapApproval, 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']:
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('scrap_approval')
def approve_scrap_request(request_id):
"""审批报废申请(仅被指定审批人)"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
data = request.get_json() or {}
req = ScrapApprovalService.approve(
request_id, int(get_jwt_identity()),
data.get('action'), data.get('reject_reason', '')
)
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
@jwt_required()
@permission_required('scrap_execute')
def execute_scrap_request(request_id):
"""
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = int(get_jwt_identity())
u = SysUser.query.get(identity)
data = request.get_json(silent=True) or {}
req = ScrapApprovalService.execute(
request_id,
operator_name=(u.username if u else str(identity)),
scanned_items=data.get('items') or [],
)
return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500