feat(scrap): 报废申请审批流 + 按单报废(后端)

- ScrapApproval 模型 + ScrapApprovalService:提交/列表/审批/执行(锁库存扣 available_quantity 写 TransScrap)
- 新路由 POST /scrap/request、/request/check-approval、GET /request、PATCH /request/<id>/approve、POST /request/<id>/execute;权限码 scrap_apply/scrap_approval/scrap_execute(无角色硬编码)
- 旧 /scrap 直接报废入口保持不变
- /inbound/stock/list 每项返回 source_table,供按单流程精准选库存
This commit is contained in:
yueli
2026-09-10 09:46:57 +08:00
parent 3d8fb198c5
commit ffbb2199f0
4 changed files with 486 additions and 0 deletions

View File

@ -315,6 +315,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_buy' # ★ 报废等按单流程需要精准定位来源表
d['stock_type'] = 'material'
d['type'] = 'material'
d['typeLabel'] = '采购件'
@ -341,6 +342,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_semi' # ★ 精准来源表
d['stock_type'] = 'semi'
d['type'] = 'semi'
d['typeLabel'] = '半成品'
@ -370,6 +372,7 @@ def _do_get_stock_list(permission_prefix=None):
rows = q.all()
for item in rows:
d = item.to_dict()
d['source_table'] = 'stock_product' # ★ 精准来源表
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'

View File

@ -466,3 +466,159 @@ class ScrapService:
'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 权限者执行,扣减实物库存并写报废流水"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = int(get_jwt_identity())
u = SysUser.query.get(identity)
req = ScrapApprovalService.execute(request_id, operator_name=(u.username if u else str(identity)))
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