2026-03-19 09:49:21 +08:00
|
|
|
|
# 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
|
2026-09-10 14:16:27 +08:00
|
|
|
|
from app.utils.decorators import permission_required, get_current_company_filter
|
2026-03-19 09:49:21 +08:00
|
|
|
|
from app.services.auth_service import AuthService
|
|
|
|
|
|
from app.extensions import db
|
2026-04-09 09:53:20 +08:00
|
|
|
|
from app.models.transaction import TransScrap, TransRepair
|
2026-03-19 09:49:21 +08:00
|
|
|
|
from app.models.inbound.buy import StockBuy
|
|
|
|
|
|
from app.models.inbound.semi import StockSemi
|
|
|
|
|
|
from app.models.inbound.product import StockProduct
|
2026-04-09 17:49:51 +08:00
|
|
|
|
from app.models.base import MaterialBase
|
|
|
|
|
|
from app.models.system import SysUser
|
2026-03-19 09:49:21 +08:00
|
|
|
|
import traceback
|
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
|
|
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 12:06:04 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 报废记录字段级权限过滤(对齐 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
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')
|
2026-07-13 15:12:22 +08:00
|
|
|
|
user_company = claims.get('company_name', '')
|
2026-03-19 09:49:21 +08:00
|
|
|
|
if not user_role:
|
|
|
|
|
|
return []
|
|
|
|
|
|
if user_role.upper() == 'SUPER_ADMIN':
|
|
|
|
|
|
return ['scrap_list:*']
|
2026-07-13 15:12:22 +08:00
|
|
|
|
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
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:
|
2026-07-16 11:26:05 +08:00
|
|
|
|
# ★ Fail-Closed: 扫码响应剥离价格字段
|
|
|
|
|
|
result.pop('price', None)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
return jsonify({'code': 200, 'msg': '扫描成功', 'data': result})
|
|
|
|
|
|
else:
|
|
|
|
|
|
return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404
|
|
|
|
|
|
except Exception as e:
|
2026-03-26 16:59:14 +08:00
|
|
|
|
import traceback
|
|
|
|
|
|
traceback.print_exc() # 强制在控制台打印真实错误堆栈
|
|
|
|
|
|
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
|
2026-03-19 09:49:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------
|
|
|
|
|
|
# 2. 提交报废单接口
|
|
|
|
|
|
# POST /api/v1/scrap
|
|
|
|
|
|
# --------------------------------------------------------
|
|
|
|
|
|
@scrap_bp.route('', methods=['POST'])
|
|
|
|
|
|
@jwt_required()
|
|
|
|
|
|
def create_scrap():
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
user_role = claims.get('role')
|
2026-07-13 15:12:22 +08:00
|
|
|
|
user_company = claims.get('company_name', '')
|
2026-03-19 09:49:21 +08:00
|
|
|
|
if not user_role:
|
|
|
|
|
|
return jsonify({'code': 403, 'msg': '未授权'}), 403
|
|
|
|
|
|
|
|
|
|
|
|
# 超级管理员直接放行
|
|
|
|
|
|
if user_role.upper() != 'SUPER_ADMIN':
|
2026-07-13 15:12:22 +08:00
|
|
|
|
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 11:32:44 +08:00
|
|
|
|
# --------------------------------------------------------
|
|
|
|
|
|
# 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')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
# --------------------------------------------------------
|
|
|
|
|
|
# 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', '')
|
2026-09-10 12:06:04 +08:00
|
|
|
|
keyword = request.args.get('keyword', '')
|
|
|
|
|
|
search_type = request.args.get('search_type', 'all')
|
2026-03-19 09:49:21 +08:00
|
|
|
|
|
2026-09-10 13:06:07 +08:00
|
|
|
|
# ★ 高级筛选:JSON 字符串 → 条件列表
|
|
|
|
|
|
from app.utils.advanced_filter import parse_advanced_filters
|
|
|
|
|
|
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
|
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = ScrapService.query_records(
|
|
|
|
|
|
page=page,
|
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
|
sku=sku,
|
|
|
|
|
|
start_date=start_date,
|
2026-09-10 12:06:04 +08:00
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
keyword=keyword,
|
|
|
|
|
|
search_type=search_type,
|
2026-09-10 13:06:07 +08:00
|
|
|
|
advanced_filters=advanced_filters,
|
2026-03-19 09:49:21 +08:00
|
|
|
|
)
|
2026-09-10 12:06:04 +08:00
|
|
|
|
# 损失金额按 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)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-09 09:53:20 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_stock(item, table_type):
|
2026-03-26 17:20:43 +08:00
|
|
|
|
"""格式化库存查询结果 - 使用安全 getattr 防止属性错误"""
|
2026-03-19 09:49:21 +08:00
|
|
|
|
return {
|
2026-03-26 17:20:43 +08:00
|
|
|
|
'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),
|
2026-03-19 09:49:21 +08:00
|
|
|
|
'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
|
|
|
|
|
|
|
2026-04-09 09:53:20 +08:00
|
|
|
|
# 处理维修单报废
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-15 17:37:57 +08:00
|
|
|
|
# 获取库存记录 — ★ 修复并发:使用悲观锁防止超卖/负库存
|
2026-03-19 09:49:21 +08:00
|
|
|
|
stock_record = None
|
|
|
|
|
|
if source_table == 'stock_product':
|
2026-07-15 17:37:57 +08:00
|
|
|
|
stock_record = StockProduct.query.with_for_update().get(stock_id)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
elif source_table == 'stock_semi':
|
2026-07-15 17:37:57 +08:00
|
|
|
|
stock_record = StockSemi.query.with_for_update().get(stock_id)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
elif source_table == 'stock_buy':
|
2026-07-15 17:37:57 +08:00
|
|
|
|
stock_record = StockBuy.query.with_for_update().get(stock_id)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
|
|
|
|
|
|
if not stock_record:
|
|
|
|
|
|
raise ValueError(f'库存记录不存在: ID={stock_id}')
|
|
|
|
|
|
|
2026-07-15 17:37:57 +08:00
|
|
|
|
# 检查可用数量(锁已持有,TOCTOU 窗口已消除)
|
2026-03-19 09:49:21 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
|
2026-09-10 12:06:04 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-03-19 09:49:21 +08:00
|
|
|
|
@staticmethod
|
2026-09-10 12:06:04 +08:00
|
|
|
|
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='',
|
2026-09-10 13:06:07 +08:00
|
|
|
|
keyword='', search_type='all', advanced_filters=None):
|
2026-09-10 12:06:04 +08:00
|
|
|
|
"""
|
|
|
|
|
|
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
|
|
|
|
|
|
|
|
|
|
|
|
· 有 scrap_request_no:按单号分组;
|
|
|
|
|
|
· 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组,
|
|
|
|
|
|
并生成可读的虚拟单号,避免历史数据成为无主记录。
|
|
|
|
|
|
每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。
|
|
|
|
|
|
"""
|
2026-03-19 09:49:21 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
2026-09-10 12:06:04 +08:00
|
|
|
|
# 单号 / 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}%'))
|
|
|
|
|
|
|
2026-09-10 13:06:07 +08:00
|
|
|
|
# ====================================================================
|
|
|
|
|
|
# ★ 高级筛选
|
|
|
|
|
|
#
|
|
|
|
|
|
# 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组,
|
|
|
|
|
|
# 且历史数据 scrap_request_no 为 NULL,无法像出库/借还那样直接对
|
|
|
|
|
|
# 单号列做 IN。因此这里统一用**行级等价键**表达父子关系:
|
|
|
|
|
|
#
|
|
|
|
|
|
# 1) 先用子级条件求出「命中行」;
|
|
|
|
|
|
# 2) 把命中行折算成"同单等价键"谓词;
|
|
|
|
|
|
# 3) 肯定操作符 → 放行同单全部行(OR),
|
|
|
|
|
|
# 否定操作符 → 整单排除(NOT ...)。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 等价键:有单号 → scrap_request_no 相等;
|
|
|
|
|
|
# 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。
|
|
|
|
|
|
# ====================================================================
|
|
|
|
|
|
if advanced_filters:
|
|
|
|
|
|
from app.utils.advanced_filter import (
|
|
|
|
|
|
build_predicate, is_negative, invert_condition,
|
|
|
|
|
|
)
|
|
|
|
|
|
from sqlalchemy import or_, and_, tuple_, func as _func
|
|
|
|
|
|
|
|
|
|
|
|
parent_map = {
|
|
|
|
|
|
'no': TransScrap.scrap_request_no,
|
|
|
|
|
|
'operator': TransScrap.operator_name,
|
|
|
|
|
|
}
|
|
|
|
|
|
child_map = {'sku': TransScrap.sku}
|
|
|
|
|
|
|
|
|
|
|
|
def _order_key_pred(rows):
|
|
|
|
|
|
"""把若干命中行折算成「同单」谓词(OR 连接)"""
|
|
|
|
|
|
keys = []
|
|
|
|
|
|
for h in rows:
|
|
|
|
|
|
if h.scrap_request_no:
|
|
|
|
|
|
keys.append(and_(
|
|
|
|
|
|
TransScrap.scrap_request_no.isnot(None),
|
|
|
|
|
|
TransScrap.scrap_request_no == h.scrap_request_no,
|
|
|
|
|
|
))
|
|
|
|
|
|
else:
|
|
|
|
|
|
keys.append(and_(
|
|
|
|
|
|
TransScrap.scrap_request_no.is_(None),
|
|
|
|
|
|
_func.date_trunc('minute', TransScrap.operation_time)
|
|
|
|
|
|
== _func.date_trunc('minute', h.operation_time),
|
|
|
|
|
|
TransScrap.operator_name == h.operator_name,
|
|
|
|
|
|
))
|
|
|
|
|
|
return or_(*keys) if keys else None
|
|
|
|
|
|
|
|
|
|
|
|
def _matched_rows(cond):
|
|
|
|
|
|
"""按肯定形式求出「命中该子级条件的报废行」"""
|
|
|
|
|
|
probe = invert_condition(cond)
|
|
|
|
|
|
field = probe.get('field')
|
|
|
|
|
|
if field == 'material_name':
|
|
|
|
|
|
# 物料名不在流水表:先在三张库存表求出 (source_table, stock_id)
|
|
|
|
|
|
# 命中集合,再用元组 IN 定位报废行。
|
|
|
|
|
|
# ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的
|
|
|
|
|
|
# 历史行永远匹配不上,会把历史数据整体漏掉。
|
|
|
|
|
|
from app.models.base import MaterialBase
|
|
|
|
|
|
name_col = MaterialBase.name
|
|
|
|
|
|
name_pred = (name_col == probe.get('value')
|
|
|
|
|
|
if probe.get('operator') == 'eq'
|
|
|
|
|
|
else name_col.ilike(f"%{probe.get('value')}%"))
|
|
|
|
|
|
pairs = []
|
|
|
|
|
|
for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'),
|
|
|
|
|
|
(StockProduct, 'stock_product')]:
|
|
|
|
|
|
for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id)
|
|
|
|
|
|
.filter(name_pred).all()):
|
|
|
|
|
|
pairs.append((sv, r.id))
|
|
|
|
|
|
if not pairs:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return TransScrap.query.filter(
|
|
|
|
|
|
tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs)
|
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
|
|
p = build_predicate(probe, child_map)
|
|
|
|
|
|
if p is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return TransScrap.query.filter(p).all()
|
|
|
|
|
|
|
|
|
|
|
|
for cond in advanced_filters:
|
|
|
|
|
|
field = cond.get('field')
|
|
|
|
|
|
|
|
|
|
|
|
# --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 ---
|
|
|
|
|
|
if field in parent_map:
|
|
|
|
|
|
p = build_predicate(cond, parent_map)
|
|
|
|
|
|
if p is not None:
|
|
|
|
|
|
query = query.filter(p)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if field not in child_map and field != 'material_name':
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# --- 子级字段:正/负操作符语义分派 ---
|
|
|
|
|
|
negative = is_negative(cond)
|
|
|
|
|
|
rows = _matched_rows(cond)
|
|
|
|
|
|
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
# 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单
|
|
|
|
|
|
if not negative:
|
|
|
|
|
|
query = query.filter(db.false())
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
key_pred = _order_key_pred(rows)
|
|
|
|
|
|
if key_pred is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
query = query.filter(~key_pred if negative else key_pred)
|
|
|
|
|
|
|
2026-07-15 11:11:40 +08:00
|
|
|
|
# 【行级数据隔离】基于 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
|
|
|
|
|
|
)
|
2026-08-31 10:21:28 +08:00
|
|
|
|
# ★ 借库转报废: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()
|
2026-07-15 11:11:40 +08:00
|
|
|
|
query = query.filter(TransScrap.id.in_(all_matches))
|
|
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
# 按时间倒序
|
|
|
|
|
|
query = query.order_by(TransScrap.operation_time.desc())
|
|
|
|
|
|
|
2026-09-10 12:06:04 +08:00
|
|
|
|
# ==================================================================
|
|
|
|
|
|
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
|
|
|
|
|
|
# 有单号 → 按 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)
|
2026-04-09 17:49:51 +08:00
|
|
|
|
|
2026-03-19 09:49:21 +08:00
|
|
|
|
return {
|
2026-09-10 12:06:04 +08:00
|
|
|
|
'list': paged,
|
2026-03-19 09:49:21 +08:00
|
|
|
|
'total': total,
|
|
|
|
|
|
'page': page,
|
|
|
|
|
|
'pageSize': page_size
|
|
|
|
|
|
}
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架
|
|
|
|
|
|
# 与旧 /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():
|
2026-09-10 11:32:51 +08:00
|
|
|
|
"""
|
|
|
|
|
|
提交前预检:报废一律需审批,本接口返回 need_approval=true,
|
|
|
|
|
|
并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。
|
|
|
|
|
|
"""
|
2026-09-10 09:46:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.services.approval_control import resolve_approval_control
|
2026-09-10 11:32:51 +08:00
|
|
|
|
from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL
|
2026-09-10 09:46:57 +08:00
|
|
|
|
data = request.get_json() or {}
|
|
|
|
|
|
items = data.get('items', []) or []
|
2026-09-10 11:32:51 +08:00
|
|
|
|
_, flagged = resolve_approval_control(items)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
return jsonify({'code': 200, 'msg': 'success',
|
2026-09-10 11:32:51 +08:00
|
|
|
|
'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL,
|
|
|
|
|
|
'materials': flagged}}), 200
|
2026-09-10 09:46:57 +08:00
|
|
|
|
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):
|
2026-09-10 10:14:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
|
|
|
|
|
|
|
|
|
|
|
|
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
|
|
|
|
|
|
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
|
|
|
|
|
|
"""
|
2026-09-10 09:46:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.services.scrap_approval_service import ScrapApprovalService
|
|
|
|
|
|
identity = int(get_jwt_identity())
|
|
|
|
|
|
u = SysUser.query.get(identity)
|
2026-09-10 10:14:40 +08:00
|
|
|
|
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 [],
|
|
|
|
|
|
)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
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
|