diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 58ac13a..5e4af32 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -184,6 +184,19 @@ def create_app(): except ImportError as e: print(f"❌ 错误: Audit 模块导入失败: {e}") + # ----------------------------------------------------- + # 2.8 我的申请单(跨模块聚合,申请人视角) + # 仅需登录态,applicant_id 硬编码为当前用户; + # 与各模块的审批端点(管理视角)严格分离。 + # ----------------------------------------------------- + try: + from app.api.v1.my_requests import my_requests_bp + app.register_blueprint(my_requests_bp, url_prefix='/api/v1/my-requests') + app.register_blueprint(my_requests_bp, url_prefix='/api/my-requests', name='my_requests_legacy') + print("✅ MyRequests 模块注册成功") + except ImportError as e: + print(f"❌ 错误: MyRequests 模块导入失败: {e}") + # 2.9 初始化审计日志菜单和权限(防重复) # ----------------------------------------------------- try: diff --git a/inventory-backend/app/api/v1/my_requests.py b/inventory-backend/app/api/v1/my_requests.py new file mode 100644 index 0000000..fdc50c3 --- /dev/null +++ b/inventory-backend/app/api/v1/my_requests.py @@ -0,0 +1,184 @@ +# inventory-backend/app/api/v1/my_requests.py +""" +「我的申请单」跨模块聚合接口(申请人视角)。 + +设计动机 +-------- +出库 / 借库 / 报废三个模块各有一套审批流,申请人此前**没有查看自己单据的 +入口** —— 审批页是管理视角(需 xxx_approval 权限),普通员工进不去。 + +本模块提供一个只读聚合视图:一个页面看全部申请。 + +为什么单独建一个蓝图,而不是在三个模块各加一个端点 +-------------------------------------------------- +权限模型不同:审批端点是「管理视角」,本端点是「申请人视角」。 +把两者塞进同一个端点(如 `if not privileged: applicant_id = me`) +会让管理逻辑与用户逻辑混流,一旦 is_privileged_viewer() 判定出错即越权。 +此处从设计上就没有「看别人」的分支 —— applicant_id 硬编码为当前登录用户。 + +只读保证 +-------- +本模块**只做查询**,不修改任何数据。撤回等写操作仍由各模块自己的端点承担 +(因为三者的释放逻辑不同:出库/借库已接入预占,报废尚未接入)。 +把风险锁在只读层,避免聚合逻辑的 bug 破坏业务数据。 +""" +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +import traceback + +my_requests_bp = Blueprint('my_requests', __name__) + +# 申请类型 → (模型, 展示名, 数量字段名) +# +# 数量字段差异:出库/借库用 quantity,报废用 scrap_qty。 +# 统一归一化为 quantity 后返回,前端只认一个字段名,避免出现 +# 「报废行的数量列显示空白」这类不报错的隐性 bug。 +_MODULE_SPECS = ( + ('outbound', 'outbound', '出库申请', 'quantity'), + ('borrow', 'borrow', '借库申请', 'quantity'), + ('scrap', 'scrap_approval', '报废申请', 'scrap_qty'), +) + + +def _load_models(): + """延迟导入三个模型,避免模块级循环依赖""" + from app.models.outbound import OutboundApproval + from app.models.borrow import BorrowApproval + from app.models.scrap_approval import ScrapApproval + return { + 'outbound': OutboundApproval, + 'borrow': BorrowApproval, + 'scrap': ScrapApproval, + } + + +def _normalize_items(items, qty_field): + """ + 明细归一化:把各模块的字段差异抹平。 + + · 统一数量字段为 quantity(报废的 scrap_qty → quantity) + · 补齐 location(报废用 location,出库/借库用 warehouse_location) + """ + normalized = [] + for it in items or []: + if not isinstance(it, dict): + continue + row = dict(it) + # 数量字段归一 + if 'quantity' not in row or row.get('quantity') in (None, ''): + row['quantity'] = row.get(qty_field) + # 库位字段归一 + if not row.get('warehouse_location'): + row['warehouse_location'] = row.get('location') or '' + normalized.append(row) + return normalized + + +@my_requests_bp.route('', methods=['GET']) +@jwt_required() +def get_my_requests(): + """ + 当前登录用户的全部申请单(出库 + 借库 + 报废)。 + + Query: + page : 页码,默认 1(跨三类统一分页) + limit : 每页数量,默认 10 + status : 可选,按状态过滤(0待审 1已通过 2已驳回 3已完成 4已撤回) + type : 可选,outbound / borrow / scrap,只看某一类 + + ★ 权限:仅 @jwt_required。applicant_id 硬编码为当前登录用户, + 不接受任何入参覆盖 —— 普通申请人无需任何审批类权限即可使用, + 且从设计上不可能查到他人单据。 + """ + try: + identity = get_jwt_identity() + if not identity: + return jsonify({'code': 401, 'msg': '用户未登录'}), 401 + me = int(identity) + + page = max(int(request.args.get('page', 1)), 1) + limit = min(max(int(request.args.get('limit', 10)), 1), 100) + status = request.args.get('status') + status = int(status) if status not in (None, '', 'all') else None + want_type = (request.args.get('type') or 'all').strip().lower() + + models = _load_models() + + # ---- 收集三类单据(各自按 applicant_id 过滤)---- + collected = [] + for type_key, _model_name, type_label, qty_field in _MODULE_SPECS: + if want_type != 'all' and want_type != type_key: + continue + model = models.get(type_key) + if model is None: + continue + + try: + q = model.query.filter(model.applicant_id == me) + if status is not None: + q = q.filter(model.status == status) + rows = q.all() + except Exception as e: + # 单模块查询失败不应让整个聚合接口挂掉(例如该表尚未迁移) + import logging + logging.getLogger(__name__).error( + f"[my-requests] {type_key} 查询失败: {type(e).__name__}: {e}" + ) + continue + + for r in rows: + d = r.to_dict() + collected.append({ + 'type': type_key, + 'type_label': type_label, + 'id': d.get('id'), + 'request_no': d.get('request_no'), + 'applicant_id': d.get('applicant_id'), + 'remark': d.get('remark') or '', + 'status': d.get('status'), + 'reject_reason': d.get('reject_reason') or '', + 'created_at': d.get('created_at'), + # 模块特有字段(缺失时为 '',前端按 type 决定是否展示) + 'outbound_type': d.get('outbound_type') or '', + 'borrower_name': d.get('borrower_name') or '', + 'items': _normalize_items(d.get('items'), qty_field), + # 撤回端点路径:前端据此分发到各模块(写操作不在此聚合) + 'withdraw_endpoint': _withdraw_path(type_key, d.get('id')), + }) + + # ---- 统一按创建时间倒序,再跨类分页 ---- + collected.sort(key=lambda x: x.get('created_at') or '', reverse=True) + total = len(collected) + start = (page - 1) * limit + paged = collected[start:start + limit] + + return jsonify({ + 'code': 200, 'msg': 'success', + 'data': { + 'items': paged, + 'total': total, + 'page': page, + 'pageSize': limit, + } + }), 200 + + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'获取我的申请单失败: {str(e)}'}), 500 + + +def _withdraw_path(type_key, req_id): + """ + 撤回端点路径。撤回是**写操作**,保留在各模块自己的端点里 + (三者释放逻辑不同),此处只提供路径供前端分发,不在此执行。 + """ + if req_id is None: + return '' + if type_key == 'outbound': + return f"/api/v1/outbound/request/{req_id}/withdraw" + if type_key == 'borrow': + # 借库暂用 close 端点(语义为撤回/作废) + return f"/api/v1/transactions/borrow/request/{req_id}/close" + if type_key == 'scrap': + return f"/api/v1/scrap/request/{req_id}/withdraw" + return ''