From 077fd2f2cfe1514144f3444bb9f63e8b10eea11d Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 15:46:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow,scrap):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E4=BA=BA=E6=92=A4=E5=9B=9E=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E4=B8=8E=E5=87=BA=E5=BA=93=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 出库已有独立的申请人撤回端点(仅 @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//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 --- inventory-backend/app/api/v1/scrap.py | 21 ++++++-- inventory-backend/app/api/v1/transactions.py | 38 +++++++++++++ .../app/services/borrow_service.py | 54 +++++++++++++++++-- .../app/services/scrap_approval_service.py | 28 ++++++++-- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index b7f6259..cd9ca20 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -892,20 +892,35 @@ def approve_scrap_request(request_id): @scrap_bp.route('/request//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: diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index ec5a5a7..46e65be 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -318,6 +318,44 @@ def close_borrow_request(request_id): return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 +@trans_bp.route('/borrow/request//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() diff --git a/inventory-backend/app/services/borrow_service.py b/inventory-backend/app/services/borrow_service.py index 5f26aa1..df81e17 100644 --- a/inventory-backend/app/services/borrow_service.py +++ b/inventory-backend/app/services/borrow_service.py @@ -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 \ No newline at end of file diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index a2143c8..926b1c8 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -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