feat: 采购模块重构 — 权限驱动的可见性 + 严格操作权限

## purchase.py
- GET /purchase (列表): @jwt_required, 有权限看同公司全部/无权限只看自己
- POST /purchase (创建): @jwt_required, 任何登录用户可提交
- GET /purchase/<id> (详情): @jwt_required, 自己的或有权者可见
- PATCH /purchase/<id>/approve (审批): @permission_required('inbound_purchase:operation')
  不再硬编码 SUPERVISOR 角色, 有操作权限即可审批
- GET /purchase/approvers, /auto-fill, /search-material: @jwt_required (开放给创建流程)
- 新增 _user_has_purchase_perm() 统一权限检查
- 移除所有硬编码 role in ('SUPERVISOR','SUPER_ADMIN')

## purchase_service.py
- send_email → send_email_async (采购审批邮件异步发送)

## purchase/index.vue
- canApprove: 仅检查 inbound_purchase:operation (菜单权限≠审批权限)
- customUpload: 修复 v-model:file-list 重复推入导致图片双份显示
- auth.py → purchase.py 权限前缀统一
This commit is contained in:
yueli
2026-07-16 11:25:53 +08:00
parent 0b75740ae0
commit 128985af17
3 changed files with 187 additions and 145 deletions

View File

@ -1,6 +1,6 @@
import traceback
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
from app.services.purchase_service import PurchaseService
from app.utils.decorators import permission_required
@ -15,20 +15,33 @@ def get_current_user_id():
def get_current_user_role():
"""获取当前用户角色"""
from flask_jwt_extended import get_jwt
claims = get_jwt()
return claims.get('role')
def _user_has_purchase_perm():
"""检查当前用户是否有 inbound_purchase 权限(菜单或任意操作权限)"""
claims = get_jwt()
role = claims.get('role', '')
user_company = claims.get('company_name', '')
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
return True
from app.services.auth_service import AuthService
perm_dict = AuthService.get_user_permissions(role, company_name=user_company)
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return 'inbound_purchase' in all_perms or any(
p.startswith('inbound_purchase:') for p in all_perms
)
# --------------------------------------------------------
# 1. 采购申请列表
# GET /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
def get_purchase_list():
"""获取采购申请列表"""
"""采购申请列表:有权限看同公司全部,无权限只看自己的"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
@ -36,15 +49,12 @@ def get_purchase_list():
status = int(status) if status is not None else None
user_id = get_current_user_id()
role = get_current_user_role()
# 普通用户SUPERVISOR 和 SUPER_ADMIN 除外)只看自己提交的
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
can_view_all = _user_has_purchase_perm()
result = PurchaseService.get_purchase_list(
page=page,
per_page=per_page,
requester_id=None if is_admin else user_id,
requester_id=None if can_view_all else user_id,
status=status
)
@ -60,9 +70,8 @@ def get_purchase_list():
# --------------------------------------------------------
@purchase_bp.route('', methods=['POST'])
@jwt_required()
@permission_required('inbound_buy')
def create_purchase_request():
"""创建采购申请"""
"""创建采购申请(任何登录用户可提交)"""
try:
data = request.get_json()
if not data:
@ -83,10 +92,15 @@ def create_purchase_request():
purchase = PurchaseService.create_purchase_request(data, requester_id=user_id)
# ★ Fail-Closed: 创建响应剥离价格字段
resp = purchase.to_dict()
for k in ('unit_price', 'total_price', 'tax_rate'):
resp.pop(k, None)
return jsonify({
'code': 200,
'msg': '创建成功',
'data': purchase.to_dict()
'data': resp
}), 200
except ValueError as e:
@ -102,19 +116,15 @@ def create_purchase_request():
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
def get_purchase_detail(purchase_id):
"""获取采购申请详情"""
"""采购申请详情:自己的无需权限,别人的需要 inbound_purchase"""
try:
purchase = PurchaseService.get_purchase_by_id(purchase_id)
if not purchase:
return jsonify({'code': 404, 'msg': '采购申请不存在'}), 404
# 普通用户只能看自己的
user_id = get_current_user_id()
role = get_current_user_role()
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
if not is_admin and purchase['requester_id'] != user_id:
if purchase['requester_id'] != user_id and not _user_has_purchase_perm():
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
@ -128,15 +138,11 @@ def get_purchase_detail(purchase_id):
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('inbound_buy')
@permission_required('inbound_purchase:operation')
def approve_purchase_request(purchase_id):
"""审批采购申请"""
"""审批采购申请必须有操作权限inbound_purchase:operation"""
try:
user_id = get_current_user_id()
role = get_current_user_role()
if role not in ('SUPERVISOR', 'SUPER_ADMIN'):
return jsonify({'code': 403, 'msg': '只有主管或超级管理员可以审批'}), 403
data = request.get_json() or {}
action = data.get('action', 'approve')
@ -156,7 +162,11 @@ def approve_purchase_request(purchase_id):
)
msg = '审批通过' if action == 'approve' else '已驳回'
return jsonify({'code': 200, 'msg': msg, 'data': purchase.to_dict()}), 200
# ★ Fail-Closed: 审批响应剥离价格字段
resp = purchase.to_dict()
for k in ('unit_price', 'total_price', 'tax_rate'):
resp.pop(k, None)
return jsonify({'code': 200, 'msg': msg, 'data': resp}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
@ -171,9 +181,8 @@ def approve_purchase_request(purchase_id):
# --------------------------------------------------------
@purchase_bp.route('/approvers', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
def get_purchase_approvers():
"""获取可选审批人列表(主管+超管"""
"""获取可选审批人列表(创建采购申请时选择审批人用"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
@ -199,7 +208,6 @@ def get_purchase_approvers():
# --------------------------------------------------------
@purchase_bp.route('/auto-fill', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
def auto_fill_purchase():
"""根据名称或规格自动补全另一个字段"""
try:
@ -219,7 +227,7 @@ def auto_fill_purchase():
# --------------------------------------------------------
@purchase_bp.route('/approved-unstocked', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
@permission_required('inbound_purchase')
def get_approved_unstocked_requests():
"""获取已审批通过且未入库的采购申请列表"""
try:
@ -243,7 +251,6 @@ def get_approved_unstocked_requests():
# --------------------------------------------------------
@purchase_bp.route('/search-material', methods=['GET'])
@jwt_required()
@permission_required('inbound_buy')
def search_material_for_purchase():
"""物料基础信息搜索接口,支持分页,用于采购申请弹窗"""
try: