diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index bd9d3bb..3536361 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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//withdraw +# -------------------------------------------------------- +@outbound_bp.route('/request//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 diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index f5fd317..f795b7d 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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()