Files
KCGL/inventory-backend/app/api/v1/purchase.py
yueli 26a1857acb fix(purchase): 待采购清单的参考价格改按 material_list:referencePrice 管控
修复一个权限旁路:待采购清单的「参考单价」原本后端无条件返回、前端列也没有
任何门控,而 material_list:referencePrice 只授予 SUPER_ADMIN 与 SUPERVISOR。

结果是 WAREHOUSE_MGR(库管) / INBOUND(入库员) / SALES(销售) 虽然都持有
inbound_purchase:pending_pool、能打开待采购清单,就会看到参考价格 —— 而这个
数字他们在物料列表里是被挡住的。等于本页面成了绕过该权限的后门。

参考价格来自 material_base.reference_price,与物料列表同源,因此复用同一个
权限码管控,不另造新码(同源数据用同码,口径才不会漂)。

改动:
- 后端 get_pending_purchase_pool 新增 include_reference_price 参数,
  fail-closed 默认 False;无权限时**整个字段不返回**而不是给 None
- 新增 _has_material_reference_price_perm(),判定方式与既有的
  _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)
- 前端「参考单价」列加 hasPermission 门控,TS 类型改为可选

★ 只挡前端等于没挡(接口仍然裸奔),前后端必须同时改。
2026-09-18 14:36:02 +08:00

481 lines
19 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.

import traceback
from flask import Blueprint, request, jsonify, current_app
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, prevent_double_submit
purchase_bp = Blueprint('purchase', __name__, url_prefix='/api/v1/purchase')
def get_current_user_id():
"""获取当前登录用户ID"""
identity = get_jwt_identity()
return identity
def get_current_user_role():
"""获取当前用户角色"""
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
)
def _filter_purchase_prices(item_dict):
"""Fail-Closed: 无价格权限则剥离采购价格字段"""
from app.services.auth_service import AuthService
claims = get_jwt()
role = claims.get('role', '')
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
return
perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', ''))
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if 'inbound_purchase:unit_price' not in all_perms:
item_dict.pop('unit_price', None)
item_dict.pop('pre_tax_unit_price', None)
item_dict.pop('post_tax_unit_price', None)
if 'inbound_purchase:total_price' not in all_perms:
item_dict.pop('total_price', None)
if 'inbound_purchase:tax_rate' not in all_perms:
item_dict.pop('tax_rate', None)
def _has_material_reference_price_perm():
"""
能否看物料的参考价格。
★ 参考价格(material_base.reference_price)是受 material_list:referencePrice
管控的价格数据,与物料列表同源。任何暴露它的新接口都必须用**同一个码**,
否则就成了绕过该权限的后门 —— 物料列表里挡住的数字,换个页面就看到了。
判定方式与 _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)。
"""
from app.services.auth_service import AuthService
claims = get_jwt()
role = claims.get('role', '')
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
return True
perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', ''))
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return 'material_list:referencePrice' in all_perms
# --------------------------------------------------------
# 1. 采购申请列表
# GET /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['GET'])
@jwt_required()
def get_purchase_list():
"""采购申请列表:有权限看同公司全部,无权限只看自己的"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
status = request.args.get('status')
status = int(status) if status not in (None, '', 'all') else None
# 搜索参数(与出库/报废记录同一套语义,便于用户迁移习惯)
keyword = (request.args.get('keyword') or '').strip()
search_type = (request.args.get('search_type') or 'all').strip()
start_date = (request.args.get('start_date') or '').strip()
end_date = (request.args.get('end_date') or '').strip()
user_id = get_current_user_id()
can_view_all = _user_has_purchase_perm()
result = PurchaseService.get_purchase_list(
page=page,
per_page=per_page,
requester_id=None if can_view_all else user_id,
status=status,
keyword=keyword,
search_type=search_type,
start_date=start_date,
end_date=end_date,
)
# ★ 字段级价格过滤
for item in (result.get('items') or []):
_filter_purchase_prices(item)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 创建采购申请
# POST /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def create_purchase_request():
"""创建采购申请(任何登录用户可提交)"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
user_id = get_current_user_id()
# 必填校验
required = ['name', 'quantity', 'purchase_date', 'approver_id']
for field in required:
if field not in data or str(data.get(field, '')).strip() == '':
return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400
# 图片必填强校验
images = data.get('images')
if not images or (isinstance(images, list) and len(images) == 0):
return jsonify({'code': 400, 'msg': '请上传采购凭证/物品图片'}), 400
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': resp
}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 3. 获取采购申请详情
# GET /api/v1/purchase/<id>
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', methods=['GET'])
@jwt_required()
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()
if purchase['requester_id'] != user_id and not _user_has_purchase_perm():
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
_filter_purchase_prices(purchase)
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 3.5 按批次查询采购申请(同一批提交的多条记录,批次=request_no去掉末尾流水号)
# GET /api/v1/purchase/batch/<batch_key>
# --------------------------------------------------------
@purchase_bp.route('/batch/<path:batch_key>', methods=['GET'])
@jwt_required()
def get_purchase_by_batch(batch_key):
"""按批次前缀(request_no 去掉末尾4位流水号)查询同一批的所有记录"""
try:
from app.models.purchase import PurchaseRequest
records = PurchaseRequest.query.filter(
PurchaseRequest.request_no.like(f"{batch_key}-%")
).order_by(PurchaseRequest.request_no.asc()).all()
user_id = get_current_user_id()
has_perm = _user_has_purchase_perm()
items = []
for r in records:
d = r.to_dict()
# 非本人且无权限时,价格字段剥离
if d['requester_id'] != user_id and not has_perm:
for k in ('unit_price', 'total_price', 'tax_rate'):
d.pop(k, None)
items.append(d)
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_key': batch_key, 'items': items}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 4. 审批采购申请
# PATCH /api/v1/purchase/<id>/approve
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
@permission_required('inbound_purchase:operation')
def approve_purchase_request(purchase_id):
"""审批采购申请:必须有操作权限(inbound_purchase:operation)"""
try:
user_id = get_current_user_id()
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject', 'close'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
purchase = PurchaseService.approve_purchase_request(
purchase_id=purchase_id,
user_id=user_id,
action=action,
reject_reason=reject_reason
)
msg = '审批通过' if action == 'approve' else ('已驳回' if action == 'reject' else '已完结')
# ★ 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
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5. 获取可选审批人列表
# GET /api/v1/purchase/approvers
# --------------------------------------------------------
@purchase_bp.route('/next-batch-seq', methods=['GET'])
@jwt_required()
def get_next_batch_seq():
"""获取今日下一个采购批次号(前端提交一批时调用,同批所有行共享)"""
try:
next_seq = PurchaseService.get_next_batch_seq()
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_seq': next_seq}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
@purchase_bp.route('/batch-approve', methods=['POST'])
@jwt_required()
@permission_required('inbound_purchase:operation')
def batch_approve_purchase():
"""
批量审批采购申请(解决循环提交导致的多条记录需多次审批问题)
请求体:
{
"ids": [1, 2, 3], // 按 ID 列表审批
或
"batch_key": "PUR-20260831-1021", // 按批次前缀审批整批(同批流水号连续)
"action": "approve" | "reject",
"reject_reason": "驳回原因(reject 时必填)"
}
"""
try:
user_id = get_current_user_id()
data = request.get_json() or {}
ids = data.get('ids', [])
batch_key = data.get('batch_key')
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
# 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次对应状态的记录
if batch_key:
from app.models.purchase import PurchaseRequest
# 完结本批 → 找「已通过(1)」的单;审批/驳回 → 找「待审批(0)」的单
target_status = 1 if action == 'close' else 0
records = PurchaseRequest.query.filter(
PurchaseRequest.request_no.like(f"{batch_key}-%"),
PurchaseRequest.status == target_status
).all()
ids = [r.id for r in records]
if not ids:
status_text = '已通过' if action == 'close' else '待审批'
return jsonify({'code': 400, 'msg': f'该批次没有{status_text}的记录'}), 400
if not ids or not isinstance(ids, list):
return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400
if action not in ('approve', 'reject', 'close'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success_ids = []
error_items = []
for pid in ids:
try:
purchase = PurchaseService.approve_purchase_request(
purchase_id=pid,
user_id=user_id,
action=action,
reject_reason=reject_reason
)
success_ids.append(purchase.id)
except ValueError as e:
error_items.append({'id': pid, 'msg': str(e)})
except Exception as e:
traceback.print_exc()
error_items.append({'id': pid, 'msg': str(e)})
msg = f'批量审批完成: 成功 {len(success_ids)} 条'
if error_items:
msg += f', 失败 {len(error_items)} 条'
return jsonify({'code': 200, 'msg': msg, 'data': {'success_ids': success_ids, 'errors': error_items}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@purchase_bp.route('/approvers', methods=['GET'])
@jwt_required()
def get_purchase_approvers():
"""获取可选审批人列表(创建采购申请时选择审批人用)"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'code': 200,
'msg': '获取成功',
'data': [
{'id': u.id, 'username': u.username, 'email': u.email or '', 'role': u.role}
for u in users
]
}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 6. 根据名称/规格自动补全
# GET /api/v1/purchase/auto-fill?keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/auto-fill', methods=['GET'])
@jwt_required()
def auto_fill_purchase():
"""根据名称或规格自动补全另一个字段"""
try:
keyword = request.args.get('keyword', '').strip()
if not keyword:
return jsonify({'code': 200, 'msg': 'ok', 'data': None}), 200
result = PurchaseService.auto_fill_from_material(keyword)
return jsonify({'code': 200, 'msg': 'ok', 'data': result}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 已审批且未入库的采购单列表(供库管按单入库使用)
# GET /api/v1/purchase/approved-unstocked?page=1&keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/approved-unstocked', methods=['GET'])
@jwt_required()
@permission_required('inbound_purchase')
def get_approved_unstocked_requests():
"""获取已审批通过且未入库的采购申请列表"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
keyword = request.args.get('keyword', '').strip() or None
result = PurchaseService.get_approved_requests(
page=page, per_page=per_page, keyword=keyword
)
# ★ 注意:不在此处过滤价格。此端点用于按单入库,
# 价格数据需随响应传递到入库表单(前端通过 inbound_buy:unit_price 权限控制写入)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
# --------------------------------------------------------
# 8. 物料基础信息搜索(分页)
# GET /api/v1/purchase/search-material?keyword=xxx&page=1
# --------------------------------------------------------
@purchase_bp.route('/search-material', methods=['GET'])
@jwt_required()
def search_material_for_purchase():
"""物料基础信息搜索接口,支持分页,用于采购申请弹窗"""
try:
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
limit = 20
result = PurchaseService.search_base_material(keyword, page, limit)
return jsonify({
'code': 200,
'msg': 'success',
'data': result['items'],
'total': result['total'],
'has_next': result['has_next']
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 9. 待采购池(防重复采购)
# GET /api/v1/purchase/pending-pool?page=1&limit=20&keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/pending-pool', methods=['GET'])
@jwt_required()
@permission_required('inbound_purchase:pending_pool')
def get_pending_purchase_pool():
"""
待采购清单:库存已触及预警线、且没有活跃采购单的物料。
防重逻辑的核心 —— 物料一旦有在途采购单(待审批/已通过/部分到货未齐),
立即从本列表中消失;单据被驳回或强制结案后才会重新回流。
"""
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
keyword = request.args.get('keyword', '').strip() or None
category = request.args.get('category', '').strip() or None
material_type = request.args.get('type', '').strip() or None
warning_status = request.args.get('warning_status', type=int)
result = PurchaseService.get_pending_purchase_pool(
page=page, per_page=limit, keyword=keyword,
category=category, material_type=material_type,
warning_status=warning_status,
# 参考价格按 material_list:referencePrice 管控,无权限时不返回该字段
include_reference_price=_has_material_reference_price_perm(),
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500