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:
@ -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