Files
KCGL/inventory-backend/app/api/v1/my_requests.py
yueli b67d577616 feat(my-requests): 跨模块聚合端点,一个页面看全部申请
动机
----
出库/借库/报废三个模块各有一套审批流,申请人此前没有统一入口。
新增只读聚合视图,把三类单据合并返回。

为什么单独建蓝图
----------------
权限模型不同:审批端点是「管理视角」,本端点是「申请人视角」。
把两者塞进同一端点(if not privileged: applicant_id = me)会让管理逻辑
与用户逻辑混流,一旦 is_privileged_viewer() 判定出错即越权。
本模块从设计上就没有「看别人」的分支 —— applicant_id 硬编码为当前用户。

只读保证
--------
本模块只做查询,不修改任何数据。撤回等写操作仍由各模块自己的端点承担
(因为三者释放逻辑不同:出库/借库已接入预占,报废尚未接入)。
把风险锁在只读层,即使聚合逻辑有 bug 也不会破坏业务数据。

字段归一化
----------
三个模块的 items_json 存在差异,统一在服务端抹平:
  · 数量字段:报废用 scrap_qty,出库/借库用 quantity → 统一为 quantity
  · 库位字段:报废用 location → 统一为 warehouse_location
这专门避免「报废行的数量列显示空白」这类不报错的隐性 bug。

健壮性
------
单个模块查询失败时记日志并跳过,其余模块照常返回
(例如某张表尚未迁移时,其它两类仍可用)。

响应中附带 withdraw_endpoint 字段,前端据此分发撤回请求,
无需硬编码三个模块的 URL 映射。

实测
----
  普通员工(INBOUND 角色,无任何审批权限)访问 → 200,18 单
  三类单据齐全,type_label 正确
  报废明细数量字段归一化成功(quantity=1.0)
  B 看不到 A 的单 → 0 条
  type=scrap 过滤 → 全部为报废
2026-09-10 15:37:05 +08:00

185 lines
7.3 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):
"""
撤回端点路径。撤回是**写操作**,保留在各模块自己的端点里
(三者释放逻辑不同),此处只提供路径供前端分发,不在此执行。
"""
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 ''