feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐
背景
----
出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言),
但借库/报废没有:
· 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径),
普通员工调用返回 403;
· 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人
(scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。
结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。
借库
----
服务层拆成与管理路径并列的两条入口(与出库同构):
mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1
withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1
两者共用 _release_and_close(),释放逻辑只有一份实现。
新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。
报废
----
withdraw() 增加 require_owner 参数区分两条路径:
require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id
require_owner=False(管理路径) → 由调用方权限装饰器鉴权
WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。
移除端点上的 @permission_required('scrap_apply'),改走归属校验。
安全实测
--------
借库 B 撤 A 的单 → 403,库存仍是 27(未被释放)
借库 A 撤自己的单 → 200,30 完全释放
报废 B 撤 A 的单 → 403
报废 A 撤自己的单 → 200
主管代撤员工的单(特权路径)→ 200
This commit is contained in:
@ -892,20 +892,35 @@ def approve_scrap_request(request_id):
|
||||
|
||||
@scrap_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_apply')
|
||||
def withdraw_scrap_request(request_id):
|
||||
"""
|
||||
撤回已通过但尚未执行的报废申请(status 1 → 4)。
|
||||
撤回自己的报废申请单(待审批 或 已通过但未执行)。
|
||||
|
||||
★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可),
|
||||
权限判定完全落在「单据归属」上 —— 服务层断言
|
||||
applicant_id == 当前用户,否则 403。库管/主管可代撤。
|
||||
|
||||
与出库/借库的撤回语义一致:作废该单并释放其预占库存
|
||||
(报废模块若未接入预占,release_reserved 会安全跳过)。
|
||||
"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
if not identity:
|
||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
from app.utils.decorators import is_privileged_viewer
|
||||
|
||||
# 审批页调用时调用方已有 scrap_approval 权限;此处统一走归属校验,
|
||||
# 特权角色可代撤(is_privileged_viewer 在服务层内判定)。
|
||||
req = ScrapApprovalService.withdraw(
|
||||
request_id, operator_id=int(get_jwt_identity())
|
||||
request_id,
|
||||
operator_id=int(identity),
|
||||
require_owner=not is_privileged_viewer(),
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '报废申请已撤回', 'data': req.to_dict()}), 200
|
||||
except PermissionError as e:
|
||||
return jsonify({'code': 403, 'msg': str(e)}), 403
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
|
||||
@ -318,6 +318,44 @@ def close_borrow_request(request_id):
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
@trans_bp.route('/borrow/request/<int:request_id>/withdraw', methods=['POST'])
|
||||
@jwt_required()
|
||||
def withdraw_borrow_request(request_id):
|
||||
"""
|
||||
申请人撤回自己的借库申请单(待审批 或 已通过但未执行)。
|
||||
|
||||
★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可),
|
||||
权限判定完全落在「单据归属」上 —— 服务层会断言
|
||||
applicant_id == 当前用户,否则 403。库管/主管可代撤。
|
||||
|
||||
与 /close 的区别:/close 是管理路径(需 op_borrow_approval 权限),
|
||||
本端点是申请人路径,两者共用底层释放逻辑。
|
||||
"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
if not identity:
|
||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||
|
||||
success, message, approval = BorrowApprovalService.withdraw_request(
|
||||
request_id=request_id,
|
||||
user_id=int(identity),
|
||||
)
|
||||
|
||||
if not success:
|
||||
code = 403 if '无权' in message else 400
|
||||
return jsonify({'code': code, 'msg': message}), code
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': message,
|
||||
'data': approval.to_dict() if approval else None
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'撤回失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 借库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人) ---
|
||||
@trans_bp.route('/borrow/request/check-approval', methods=['POST'])
|
||||
@jwt_required()
|
||||
|
||||
Reference in New Issue
Block a user