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:
yueli
2026-09-10 15:36:59 +08:00
parent d1dd3dd404
commit bcbee8a194
2 changed files with 149 additions and 4 deletions

View File

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

View File

@ -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()