execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。
现改为接收实扫明细 scanned_items:
· 强制按单:未提交实扫明细一律拒绝执行;
· 键为 (source_table, stock_id),与申请单 items_json 同口径,
同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作;
· 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝;
· 允许合法子集(少扫 = 本次不报废该行);
· 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity
(报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。
接口 POST /scrap/request/<id>/execute 由无 body 改为接收 {items:[...]}。
635 lines
27 KiB
Python
635 lines
27 KiB
Python
# 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')
|
||
|
||
|
||
# ==============================================================================
|
||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||
# ==============================================================================
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 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', '')
|
||
|
||
try:
|
||
result = ScrapService.query_records(
|
||
page=page,
|
||
page_size=page_size,
|
||
sku=sku,
|
||
start_date=start_date,
|
||
end_date=end_date
|
||
)
|
||
# ★ Fail-Closed: 报废记录剥离成本字段
|
||
for item in (result.get('list') or []):
|
||
for k in ('cost_at_scrap', 'total_loss'):
|
||
item.pop(k, None)
|
||
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)}
|
||
|
||
@staticmethod
|
||
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
|
||
"""分页查询报废记录"""
|
||
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')
|
||
|
||
# 【行级数据隔离】基于 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())
|
||
|
||
total = query.count()
|
||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||
|
||
# 遍历结果,补充操作人姓名、物料名称、规格
|
||
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)
|
||
|
||
return {
|
||
'list': result_list,
|
||
'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():
|
||
"""提交前预检:判断所选库存(物料)是否命中“需审批”"""
|
||
try:
|
||
from app.services.approval_control import resolve_approval_control
|
||
data = request.get_json() or {}
|
||
items = data.get('items', []) or []
|
||
need, flagged = resolve_approval_control(items)
|
||
return jsonify({'code': 200, 'msg': 'success',
|
||
'data': {'need_approval': need, '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
|