feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点
背景
----
出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后
**没有任何入口看回自己的单据**,更谈不上撤回。
服务层:抽出共用释放逻辑
------------------------
新增 OutboundApprovalService.withdraw_request(),与既有的 close_request()
(管理路径)形成两条独立入口:
close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1
withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可
两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。
释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。
为什么单独开一条路径,而不是在 close_request 里加 if 分支:
权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易
互相影响 —— 这正是需要避免的访问控制风险。
API
---
POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required)
· 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」
· 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断
本身即执行守卫,已执行或已撤回的单都进不来
GET /outbound/my-requests 我的申请(仅 @jwt_required)
· applicant_id 硬编码为当前登录用户,不接受任何入参覆盖
两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval
(那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理
逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错
即越权;本端点从设计上就没有「看别人」的分支。
安全实测
--------
普通员工查我的申请(此前 403) → 200
B 查列表看不到 A 的单 → 39 单中无 A 的单
B 撤回 A 的单 → 403,且库存未被释放
A 撤回自己的单 → 200,库存 17→20 完全释放
主管代撤他人工单 → 200(特权路径)
待审批(status=0) 撤回 → 200,库存释放
重复撤回 → 400「当前状态不可撤回」
This commit is contained in:
@ -841,6 +841,96 @@ def close_outbound_request(request_id):
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 5.6 申请人撤回自己的申请单
|
||||
# POST /api/v1/outbound/request/<id>/withdraw
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
|
||||
@jwt_required()
|
||||
def withdraw_outbound_request(request_id):
|
||||
"""
|
||||
撤回自己的出库申请单(待审批 或 已通过但未执行)。
|
||||
|
||||
★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可),
|
||||
权限判定完全落在「单据归属」上 —— 服务层会断言
|
||||
applicant_id == 当前用户,否则 403。库管/主管可代撤。
|
||||
|
||||
与 /close 的区别:/close 是管理路径(需 outbound_approval 权限),
|
||||
本端点是申请人路径,两者共用底层释放逻辑。
|
||||
"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
if not identity:
|
||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||
|
||||
claims = get_jwt()
|
||||
success, message, approval = OutboundApprovalService.withdraw_request(
|
||||
request_id=request_id,
|
||||
user_id=int(identity),
|
||||
user_role=claims.get('role'),
|
||||
)
|
||||
|
||||
if not success:
|
||||
# 归属不符按 403 返回,其余为业务校验失败(400)
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 5.7 我的申请单(申请人视角)
|
||||
# GET /api/v1/outbound/my-requests
|
||||
#
|
||||
# ★ 严格职责分离:本端点仅需 @jwt_required,**不做模块权限校验**。
|
||||
# applicant_id 在服务端硬编码为当前登录用户,不接受任何入参覆盖 ——
|
||||
# 因此普通申请人无需持有 outbound_approval(那是管理权限),
|
||||
# 也不可能借此看到他人的单据。
|
||||
#
|
||||
# 这与「给审批端点加 if 降级放行」是两条路:后者把管理与用户逻辑
|
||||
# 混在一个端点里,一旦 is_privileged_viewer() 判定出错就会越权;
|
||||
# 本端点从设计上就没有"看别人"的分支。
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/my-requests', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_my_outbound_requests():
|
||||
"""
|
||||
查询当前登录用户提交的出库申请单。
|
||||
|
||||
Query: page / limit / status(可选,0待审 1已通过 2已驳回 3已完成 4已撤回)
|
||||
"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
if not identity:
|
||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||
|
||||
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
|
||||
|
||||
result = OutboundApprovalService.get_request_list(
|
||||
page=page,
|
||||
per_page=limit,
|
||||
applicant_id=int(identity), # ★ 硬编码,不接受入参覆盖
|
||||
status=status,
|
||||
)
|
||||
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'获取我的申请单失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 6. 获取审批单列表
|
||||
# GET /api/v1/outbound/request
|
||||
|
||||
@ -1266,18 +1266,73 @@ class OutboundApprovalService:
|
||||
if not has_outbound_op:
|
||||
return False, "您没有撤回此单的权限", None
|
||||
|
||||
return OutboundApprovalService._release_and_close(approval, user_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ★ 申请人撤回自己的申请单(严格职责分离)
|
||||
#
|
||||
# 与 close_request 的区别:
|
||||
# · close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1
|
||||
# · withdraw_request —— 申请人路径,仅校验「本人」,status 0 或 1 均可
|
||||
#
|
||||
# 为什么单独开一条路径而不是在 close_request 里加 if 分支:
|
||||
# 权限模型不同(管理角色 vs 单据归属),混在一个函数里容易在后续修改中
|
||||
# 互相影响 —— 这正是需要避免的访问控制风险。两条路径共用底层释放逻辑
|
||||
# _release_and_close,实现上不重复。
|
||||
#
|
||||
# 状态说明:
|
||||
# · status==0(待审批):提交时已预占库存,撤回同样需要释放;
|
||||
# · status==1(已通过待执行):释放预占;
|
||||
# · status>=2(已驳回/已完成/已撤回):无可撤回内容,拒绝。
|
||||
# ------------------------------------------------------------------
|
||||
WITHDRAWABLE_STATUS = (0, 1)
|
||||
|
||||
@staticmethod
|
||||
def withdraw_request(request_id, user_id, user_role=None):
|
||||
"""
|
||||
申请人撤回自己的申请单。
|
||||
|
||||
权限:仅单据本人;库管/主管/超管(is_privileged_viewer)可代撤。
|
||||
返回 (success, message, approval)
|
||||
"""
|
||||
from app.models.outbound import OutboundApproval
|
||||
from app.utils.decorators import is_privileged_viewer
|
||||
|
||||
approval = OutboundApproval.query.get(request_id)
|
||||
if not approval:
|
||||
return False, "申请单不存在", None
|
||||
|
||||
# ★ 归属校验:非本人且非特权 → 拒绝(不漏出任何单据信息)
|
||||
if not is_privileged_viewer() and int(approval.applicant_id) != int(user_id):
|
||||
return False, "无权撤回他人的申请单", None
|
||||
|
||||
if approval.status not in OutboundApprovalService.WITHDRAWABLE_STATUS:
|
||||
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已完成', 4: '已撤回'}
|
||||
return False, (
|
||||
f"当前状态不可撤回:{status_map.get(approval.status, approval.status)}"
|
||||
), None
|
||||
|
||||
return OutboundApprovalService._release_and_close(approval, user_id)
|
||||
|
||||
@staticmethod
|
||||
def _release_and_close(approval, user_id):
|
||||
"""
|
||||
撤回的共用底层:释放预占 + 置为已撤回。
|
||||
|
||||
两条入口(管理路径 close_request / 申请人路径 withdraw_request)
|
||||
各自完成权限与状态校验后调用本函数,确保释放逻辑只有一份实现,
|
||||
不会因修改其中一处而漏掉另一处。
|
||||
"""
|
||||
try:
|
||||
# ★ 释放全部预占,把 available_quantity 还回池子
|
||||
from app.services.inventory_reservation import release_reserved
|
||||
restored = release_reserved(approval.get_items())
|
||||
|
||||
approval.status = 4 # 4-已完结(撤回/作废)
|
||||
approval.status = 4 # 4-已撤回/已完结
|
||||
approval.actual_approver_id = user_id
|
||||
approval.approved_at = None
|
||||
db.session.commit()
|
||||
|
||||
msg = f"申请单已撤回,释放 {restored} 项预占库存"
|
||||
return True, msg, approval
|
||||
return True, f"申请单已撤回,释放 {restored} 项预占库存", approval
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
Reference in New Issue
Block a user