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:
yueli
2026-09-10 15:46:55 +08:00
parent c999aac1e3
commit 077fd2f2cf
4 changed files with 130 additions and 11 deletions

View File

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

View File

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

View File

@ -500,20 +500,68 @@ class BorrowApprovalService:
return False, "审批单不存在", None
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '完结'}
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '撤回'}
return False, (
f"只有已通过(待执行)的审批单才能撤回 "
f"(当前状态: {status_map.get(approval.status, approval.status)})"
), None
return BorrowApprovalService._release_and_close(approval)
# ------------------------------------------------------------------
# ★ 申请人撤回自己的借库申请单(严格职责分离)
#
# 与 mark_completed管理路径的区别
# · mark_completed —— 需 op_borrow_approval 权限,仅 status==1
# · withdraw_request —— 仅校验「单据归属」status 0 或 1 均可
#
# 两条路径共用底层 _release_and_close释放逻辑只有一份实现。
# ------------------------------------------------------------------
WITHDRAWABLE_STATUS = (0, 1)
@staticmethod
def withdraw_request(request_id, user_id):
"""
申请人撤回自己的借库申请单。
权限:仅单据本人;库管/主管/超管is_privileged_viewer可代撤。
返回 (success, message, approval)
"""
from app.utils.decorators import is_privileged_viewer
approval = BorrowApproval.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 BorrowApprovalService.WITHDRAWABLE_STATUS:
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已完成', 4: '已撤回'}
return False, (
f"当前状态不可撤回:{status_map.get(approval.status, approval.status)}"
), None
return BorrowApprovalService._release_and_close(approval)
@staticmethod
def _release_and_close(approval):
"""
撤回的共用底层:释放预占 + 置为已撤回。
两条入口(管理路径 mark_completed / 申请人路径 withdraw_request
各自完成权限与状态校验后调用本函数,确保释放逻辑不会因修改
其中一处而漏掉另一处。
"""
try:
# ★ 释放全部预占,把 available_quantity 还回池子
from app.services.inventory_reservation import release_reserved
restored = release_reserved(approval.get_items())
approval.status = 4 # 已完结(撤回/作废)
approval.status = 4 # 已撤回/已完结
db.session.commit()
return True, f"审批单已撤回,释放 {restored} 项预占库存", approval
return True, f"申请单已撤回,释放 {restored} 项预占库存", approval
except Exception as e:
db.session.rollback()
return False, f"撤回归还失败: {str(e)}", None

View File

@ -204,17 +204,35 @@ class ScrapApprovalService:
# 状态守卫「仅 status==1 可撤回」同时充当执行守卫:
# 已执行(status=3)的单无法再次撤回。
# ------------------------------------------------------------------
WITHDRAWABLE_STATUS = (0, 1)
@staticmethod
def withdraw(request_id, operator_id):
"""撤回已通过但尚未执行的报废申请单"""
def withdraw(request_id, operator_id, require_owner=True):
"""
撤回报废申请单(待审批 或 已通过但未执行)。
require_owner=True默认申请人路径
断言 applicant_id == operator_id否则拒绝特权角色可代撤
require_owner=False管理路径审批页调用
由调用方的权限装饰器scrap_approval负责鉴权。
与出库/借库保持一致的两条独立入口 + 共用释放底层。
"""
from app.utils.decorators import is_privileged_viewer
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
# ★ 归属校验(仅申请人路径)
if require_owner and not is_privileged_viewer():
if int(req.applicant_id) != int(operator_id):
raise PermissionError("无权撤回他人的申请单")
if req.status not in ScrapApprovalService.WITHDRAWABLE_STATUS:
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已执行', 4: '已撤回'}
raise ValueError(
f"仅「已通过(待执行)」的报废申请可撤回 "
f"(当前状态:{status_map.get(req.status, req.status)})"
f"当前状态不可撤回:{status_map.get(req.status, req.status)}"
)
from app.services.inventory_reservation import release_reserved