Files
KCGL/inventory-backend/app/api/v1/my_requests.py
yueli 29fc00a818 fix(my-requests): 修正 withdraw_endpoint 路径前缀导致撤回 404
现象
----
在「我的申请单」页面点撤回,前端控制台报:
  POST /api/api/v1/outbound/request/351/withdraw 404

根因
----
aggregate 端点在每条记录上回传 withdraw_endpoint,但拼接时多写了 /api:

  返回的:     /api/v1/outbound/request/351/withdraw
  axios baseURL: /api
  实际请求:   /api + /api/v1/... = /api/api/v1/...   → 404

后端路由本身是对的(/api/v1/outbound/request/<id>/withdraw 已正确注册),
问题纯在前端拼接。其它 API 封装的正确写法是只传 baseURL 之后的部分
(如 url: '/v1/outbound/my-requests'),本处是唯一破坏该约定的地方。

修复
----
返回 /v1/... 而非 /api/v1/...,并加注释说明约定。
借库项同时从 close 改为 withdraw(配合上一提交的申请人端点)。

验证方式
--------
按**前端真实的拼接方式**验证('/api' + endpoint),而非直接调后端路由:
  [outbound] /v1/outbound/request/381/withdraw            → 200
  [borrow  ] /v1/transactions/borrow/request/44/withdraw  → 200
  [scrap   ] /v1/scrap/request/5/withdraw                 → 200

教训:上一轮用 test_client 直接调后端路由验证,绕过了 axios 的 baseURL
拼接,所以后端测试全绿、前端一跑就 404。验证「前端能否调通」必须模拟
前端的请求方式。
2026-09-10 15:47:02 +08:00

189 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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):
"""
撤回端点路径。撤回是**写操作**,保留在各模块自己的端点里
(三者释放逻辑不同),此处只提供路径供前端分发,不在此执行。
★ 路径不带 /api 前缀:前端 axios 实例的 baseURL 是 '/api',
传完整路径会拼成 /api/api/v1/... 导致 404。
这里返回的是 baseURL 之后的部分(与其它 API 封装的写法一致)。
"""
if req_id is None:
return ''
if type_key == 'outbound':
return f"/v1/outbound/request/{req_id}/withdraw"
if type_key == 'borrow':
# 申请人路径:仅校验单据归属,普通员工无需 op_borrow_approval 权限
return f"/v1/transactions/borrow/request/{req_id}/withdraw"
if type_key == 'scrap':
return f"/v1/scrap/request/{req_id}/withdraw"
return ''