feat(procurement): add budget control and alerts
Budget Model Enhancements: - Add reserved_amount property (sum of pending requests) - Add available_amount property (remaining - reserved) - Add _quarter_months() helper method API Endpoints: - Add /budgets/my-department/ endpoint for user's department budget - Add /stats/overview/ for procurement statistics - Add /stats/by-department/ for department-level statistics - Register ProcurementStatsViewSet in urls.py Budget Alerts: - Add _check_budget_alert() method in ProcurementRequestViewSet - Send notifications when budget < 20% (low) or < 10% (critical) - Alerts sent to department head via NotificationService Serializers: - Add reserved_amount, available_amount to BudgetSerializer - Add BudgetDetailSerializer with pending_requests list Permissions: - Fix led_departments → headed_departments in BudgetViewSet - Allow my_department endpoint for all authenticated users Tests: - Add test_budget.py with 11 new tests - TestBudgetReservedAmount (3 tests) - TestBudgetMyDepartmentEndpoint (3 tests) - TestProcurementStatsEndpoints (4 tests) - TestBudgetAlerts (1 test) All 52 procurement tests passing: ✅ 20/20 Model tests ✅ 12/12 API tests ✅ 9/9 Workflow tests ✅ 11/11 Budget tests (NEW)
This commit is contained in:
parent
91fc46d6b9
commit
69402567e3
@ -531,6 +531,31 @@ class Budget(models.Model):
|
||||
"""Остаток бюджета."""
|
||||
return self.allocated_amount - self.spent_amount
|
||||
|
||||
@property
|
||||
def reserved_amount(self):
|
||||
"""Сумма зарезервированная pending заявками."""
|
||||
from django.db.models import Sum
|
||||
pending_sum = self.department.procurement_requests.filter(
|
||||
status=ProcurementStatus.PENDING,
|
||||
created_at__year=self.year,
|
||||
created_at__month__in=self._quarter_months()
|
||||
).aggregate(total=Sum('estimated_cost'))['total']
|
||||
return pending_sum or Decimal('0.00')
|
||||
|
||||
@property
|
||||
def available_amount(self):
|
||||
"""Доступный бюджет (с учётом резерва)."""
|
||||
return self.remaining_amount - self.reserved_amount
|
||||
|
||||
def _quarter_months(self):
|
||||
"""Получить месяцы квартала."""
|
||||
return {
|
||||
1: [1, 2, 3],
|
||||
2: [4, 5, 6],
|
||||
3: [7, 8, 9],
|
||||
4: [10, 11, 12],
|
||||
}[self.quarter]
|
||||
|
||||
@property
|
||||
def utilization_percentage(self):
|
||||
"""Процент использования бюджета."""
|
||||
|
||||
@ -463,6 +463,16 @@ class BudgetSerializer(serializers.ModelSerializer):
|
||||
decimal_places=2,
|
||||
read_only=True
|
||||
)
|
||||
reserved_amount = serializers.DecimalField(
|
||||
max_digits=14,
|
||||
decimal_places=2,
|
||||
read_only=True
|
||||
)
|
||||
available_amount = serializers.DecimalField(
|
||||
max_digits=14,
|
||||
decimal_places=2,
|
||||
read_only=True
|
||||
)
|
||||
utilization_percentage = serializers.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
@ -482,6 +492,8 @@ class BudgetSerializer(serializers.ModelSerializer):
|
||||
'allocated_amount',
|
||||
'spent_amount',
|
||||
'remaining_amount',
|
||||
'reserved_amount',
|
||||
'available_amount',
|
||||
'utilization_percentage',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@ -491,6 +503,8 @@ class BudgetSerializer(serializers.ModelSerializer):
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'remaining_amount',
|
||||
'reserved_amount',
|
||||
'available_amount',
|
||||
'utilization_percentage',
|
||||
]
|
||||
|
||||
@ -499,6 +513,24 @@ class BudgetSerializer(serializers.ModelSerializer):
|
||||
return f"Q{obj.quarter} {obj.year}"
|
||||
|
||||
|
||||
class BudgetDetailSerializer(BudgetSerializer):
|
||||
"""Расширенный сериализатор для my_department с pending заявками."""
|
||||
|
||||
pending_requests = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(BudgetSerializer.Meta):
|
||||
fields = BudgetSerializer.Meta.fields + ['pending_requests']
|
||||
|
||||
def get_pending_requests(self, obj):
|
||||
"""Получить список pending заявок этого бюджета."""
|
||||
pending = obj.department.procurement_requests.filter(
|
||||
status='pending',
|
||||
created_at__year=obj.year,
|
||||
created_at__month__in=obj._quarter_months()
|
||||
).values('id', 'title', 'estimated_cost', 'status')[:10]
|
||||
return list(pending)
|
||||
|
||||
|
||||
class SupplierSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для поставщиков."""
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from .views import (
|
||||
MaintenanceRecordViewSet,
|
||||
ProcurementItemViewSet,
|
||||
ProcurementRequestViewSet,
|
||||
ProcurementStatsViewSet,
|
||||
SupplierViewSet,
|
||||
)
|
||||
|
||||
@ -53,6 +54,11 @@ router.register(
|
||||
SupplierViewSet,
|
||||
basename='supplier'
|
||||
)
|
||||
router.register(
|
||||
r'stats',
|
||||
ProcurementStatsViewSet,
|
||||
basename='stats'
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@ -4,10 +4,11 @@ ViewSets для API модуля закупок.
|
||||
|
||||
from django.db.models import Q
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import filters, permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from employees.models import Department
|
||||
from .constants import ApprovalStatus, ProcurementStatus
|
||||
from .models import (
|
||||
Approval,
|
||||
@ -93,6 +94,65 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
Q(approvals__approver=user)
|
||||
).distinct()
|
||||
|
||||
def _check_budget_alert(self, procurement_request):
|
||||
"""Проверяет бюджет и отправляет алерт если низкий остаток."""
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
department = procurement_request.department
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
except Budget.DoesNotExist:
|
||||
return # Нет бюджета - не отправляем алерт
|
||||
|
||||
# Пороговые значения для алертов
|
||||
LOW_BUDGET_THRESHOLD = Decimal('0.20') # 20% остатка
|
||||
CRITICAL_BUDGET_THRESHOLD = Decimal('0.10') # 10% остатка
|
||||
|
||||
utilization = budget.utilization_percentage / 100
|
||||
remaining_ratio = 1 - utilization
|
||||
|
||||
# Получаем руководителя отдела
|
||||
dept_head = department.head
|
||||
if not dept_head:
|
||||
return
|
||||
|
||||
if remaining_ratio <= CRITICAL_BUDGET_THRESHOLD:
|
||||
# Критический уровень - менее 10%
|
||||
NotificationService.create_notification(
|
||||
recipient=dept_head,
|
||||
notification_type_code='budget_critical',
|
||||
title="⚠️ Критически низкий бюджет!",
|
||||
message=(
|
||||
f'Бюджет отдела "{department.name}" почти исчерпан! '
|
||||
f'Остаток: {budget.remaining_amount}₽ '
|
||||
f'({remaining_ratio*100:.1f}%)'
|
||||
),
|
||||
action_url='/procurement/budgets/',
|
||||
send_immediately=True,
|
||||
)
|
||||
elif remaining_ratio <= LOW_BUDGET_THRESHOLD:
|
||||
# Низкий уровень - менее 20%
|
||||
NotificationService.create_notification(
|
||||
recipient=dept_head,
|
||||
notification_type_code='budget_low',
|
||||
title="Низкий остаток бюджета",
|
||||
message=(
|
||||
f'Бюджет отдела "{department.name}" снижается. '
|
||||
f'Остаток: {budget.remaining_amount}₽ '
|
||||
f'({remaining_ratio*100:.1f}%)'
|
||||
),
|
||||
action_url='/procurement/budgets/',
|
||||
send_immediately=True,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""Отправить заявку на согласование."""
|
||||
@ -248,6 +308,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
send_immediately=True,
|
||||
)
|
||||
|
||||
# Проверяем бюджет и отправляем алерт если низкий
|
||||
self._check_budget_alert(procurement_request)
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -460,7 +523,11 @@ class BudgetViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Права доступа."""
|
||||
permission_classes = [CanManageBudget]
|
||||
if self.action == 'my_department':
|
||||
# my_department доступен любому авторизованному пользователю
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
permission_classes = [CanManageBudget]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
@ -472,9 +539,9 @@ class BudgetViewSet(viewsets.ModelViewSet):
|
||||
return queryset
|
||||
|
||||
# Руководители видят бюджеты своих отделов
|
||||
if user.led_departments.exists():
|
||||
if user.headed_departments.exists():
|
||||
return queryset.filter(
|
||||
department__in=user.led_departments.all()
|
||||
department__in=user.headed_departments.all()
|
||||
)
|
||||
|
||||
return queryset.none()
|
||||
@ -493,6 +560,46 @@ class BudgetViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='my-department')
|
||||
def my_department(self, request):
|
||||
"""Получить бюджет текущего квартала для отдела пользователя."""
|
||||
from django.utils import timezone
|
||||
from .serializers import BudgetDetailSerializer
|
||||
|
||||
user = request.user
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
# Получаем отдел пользователя
|
||||
user_departments = user.departments.all()
|
||||
if not user_departments.exists():
|
||||
return Response(
|
||||
{'detail': 'Вы не состоите ни в одном отделе.'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Берём первый отдел (основной)
|
||||
department = user_departments.first()
|
||||
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=department,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
except Budget.DoesNotExist:
|
||||
msg = (
|
||||
f'Бюджет для {department.name} '
|
||||
f'на Q{quarter} {now.year} не найден.'
|
||||
)
|
||||
return Response(
|
||||
{'detail': msg},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = BudgetDetailSerializer(budget)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class SupplierViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для поставщиков."""
|
||||
@ -524,3 +631,121 @@ class SupplierViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ProcurementStatsViewSet(viewsets.ViewSet):
|
||||
"""ViewSet для статистики закупок."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def overview(self, request):
|
||||
"""Общая статистика закупок."""
|
||||
from django.db.models import Sum, Count
|
||||
from django.utils import timezone
|
||||
|
||||
now = timezone.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0)
|
||||
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Базовый queryset зависит от прав пользователя
|
||||
if user.is_superuser or user.is_staff:
|
||||
base_qs = ProcurementRequest.objects.all()
|
||||
else:
|
||||
base_qs = ProcurementRequest.objects.filter(
|
||||
Q(requestor=user) |
|
||||
Q(department__in=user.departments.all())
|
||||
)
|
||||
|
||||
# Подсчёт по статусам
|
||||
by_status = dict(
|
||||
base_qs.values('status').annotate(
|
||||
count=Count('id')
|
||||
).values_list('status', 'count')
|
||||
)
|
||||
|
||||
# Подсчёт по срочности
|
||||
by_urgency = dict(
|
||||
base_qs.values('urgency').annotate(
|
||||
count=Count('id')
|
||||
).values_list('urgency', 'count')
|
||||
)
|
||||
|
||||
# Общие метрики
|
||||
total = base_qs.count()
|
||||
pending = base_qs.filter(status=ProcurementStatus.PENDING).count()
|
||||
approved_month = base_qs.filter(
|
||||
status=ProcurementStatus.APPROVED,
|
||||
updated_at__gte=month_start
|
||||
).count()
|
||||
completed_month = base_qs.filter(
|
||||
status=ProcurementStatus.COMPLETED,
|
||||
completed_at__gte=month_start
|
||||
).count()
|
||||
|
||||
# Сумма потраченного за год
|
||||
spent_year = base_qs.filter(
|
||||
status=ProcurementStatus.COMPLETED,
|
||||
completed_at__gte=year_start
|
||||
).aggregate(total=Sum('actual_cost'))['total'] or 0
|
||||
|
||||
return Response({
|
||||
'total_requests': total,
|
||||
'pending_requests': pending,
|
||||
'approved_this_month': approved_month,
|
||||
'completed_this_month': completed_month,
|
||||
'total_spent_this_year': str(spent_year),
|
||||
'by_status': by_status,
|
||||
'by_urgency': by_urgency,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-department')
|
||||
def by_department(self, request):
|
||||
"""Статистика по отделам."""
|
||||
from django.db.models import Sum
|
||||
|
||||
user = request.user
|
||||
|
||||
# Только для staff/superuser или руководителей
|
||||
if not (user.is_superuser or user.is_staff):
|
||||
if not user.headed_departments.exists():
|
||||
return Response(
|
||||
{'detail': 'Нет доступа к статистике по отделам.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
departments = user.headed_departments.all()
|
||||
else:
|
||||
departments = Department.objects.all()
|
||||
|
||||
result = []
|
||||
for dept in departments:
|
||||
requests = ProcurementRequest.objects.filter(department=dept)
|
||||
total = requests.count()
|
||||
spent = requests.filter(
|
||||
status=ProcurementStatus.COMPLETED
|
||||
).aggregate(total=Sum('actual_cost'))['total'] or 0
|
||||
|
||||
# Получаем текущий бюджет
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
quarter = (now.month - 1) // 3 + 1
|
||||
try:
|
||||
budget = Budget.objects.get(
|
||||
department=dept,
|
||||
year=now.year,
|
||||
quarter=quarter
|
||||
)
|
||||
utilization = float(budget.utilization_percentage)
|
||||
except Budget.DoesNotExist:
|
||||
utilization = 0.0
|
||||
|
||||
result.append({
|
||||
'department': {'id': dept.id, 'name': dept.name},
|
||||
'total_requests': total,
|
||||
'total_spent': str(spent),
|
||||
'budget_utilization': utilization,
|
||||
})
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
300
backend/tests/procurement/test_budget.py
Normal file
300
backend/tests/procurement/test_budget.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""
|
||||
Тесты для Budget - reserved_amount, my_department, stats endpoints.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from procurement.models import (
|
||||
Budget,
|
||||
ProcurementRequest,
|
||||
)
|
||||
from procurement.constants import ProcurementStatus, UrgencyLevel
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBudgetReservedAmount:
|
||||
"""Тесты для reserved_amount и available_amount."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, department_factory, user_factory, link_factory):
|
||||
"""Настройка тестовых данных."""
|
||||
self.department = department_factory(name='IT Reserved Test')
|
||||
now = timezone.now()
|
||||
self.quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
self.budget = Budget.objects.create(
|
||||
department=self.department,
|
||||
year=now.year,
|
||||
quarter=self.quarter,
|
||||
allocated_amount=Decimal('100000.00'),
|
||||
spent_amount=Decimal('20000.00'),
|
||||
)
|
||||
|
||||
self.user = user_factory(
|
||||
email='budgetuser@test.com',
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
)
|
||||
link_factory(self.user, self.department, is_active=True)
|
||||
|
||||
def test_reserved_amount_no_pending(self):
|
||||
"""Тест: reserved_amount = 0 если нет pending заявок."""
|
||||
assert self.budget.reserved_amount == Decimal('0.00')
|
||||
|
||||
def test_reserved_amount_with_pending(self):
|
||||
"""Тест: reserved_amount считает сумму pending заявок."""
|
||||
# Создаём pending заявку
|
||||
ProcurementRequest.objects.create(
|
||||
title='Заявка 1',
|
||||
description='Описание',
|
||||
department=self.department,
|
||||
requestor=self.user,
|
||||
status=ProcurementStatus.PENDING,
|
||||
urgency=UrgencyLevel.LOW,
|
||||
estimated_cost=Decimal('5000.00'),
|
||||
)
|
||||
ProcurementRequest.objects.create(
|
||||
title='Заявка 2',
|
||||
description='Описание',
|
||||
department=self.department,
|
||||
requestor=self.user,
|
||||
status=ProcurementStatus.PENDING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
estimated_cost=Decimal('3000.00'),
|
||||
)
|
||||
|
||||
# reserved должен быть 8000
|
||||
assert self.budget.reserved_amount == Decimal('8000.00')
|
||||
|
||||
def test_available_amount(self):
|
||||
"""Тест: available_amount = remaining - reserved."""
|
||||
ProcurementRequest.objects.create(
|
||||
title='Pending заявка',
|
||||
description='Описание',
|
||||
department=self.department,
|
||||
requestor=self.user,
|
||||
status=ProcurementStatus.PENDING,
|
||||
urgency=UrgencyLevel.LOW,
|
||||
estimated_cost=Decimal('10000.00'),
|
||||
)
|
||||
|
||||
# remaining = 100000 - 20000 = 80000
|
||||
# reserved = 10000
|
||||
# available = 80000 - 10000 = 70000
|
||||
assert self.budget.remaining_amount == Decimal('80000.00')
|
||||
assert self.budget.reserved_amount == Decimal('10000.00')
|
||||
assert self.budget.available_amount == Decimal('70000.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBudgetMyDepartmentEndpoint:
|
||||
"""Тесты для /budgets/my-department/ endpoint."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(
|
||||
self, api_client, department_factory, user_factory, link_factory
|
||||
):
|
||||
"""Настройка тестовых данных."""
|
||||
self.client = api_client
|
||||
|
||||
self.department = department_factory(name='IT My Dept Test')
|
||||
|
||||
now = timezone.now()
|
||||
self.quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
self.budget = Budget.objects.create(
|
||||
department=self.department,
|
||||
year=now.year,
|
||||
quarter=self.quarter,
|
||||
allocated_amount=Decimal('100000.00'),
|
||||
spent_amount=Decimal('30000.00'),
|
||||
)
|
||||
|
||||
self.user = user_factory(
|
||||
email='mydeptuser@test.com',
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
)
|
||||
link_factory(self.user, self.department, is_active=True)
|
||||
self.user_factory = user_factory
|
||||
|
||||
def test_my_department_success(self):
|
||||
"""Тест: получение бюджета своего отдела."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['department'] == self.department.id
|
||||
allocated = Decimal(response.data['allocated_amount'])
|
||||
assert allocated == Decimal('100000.00')
|
||||
spent = Decimal(response.data['spent_amount'])
|
||||
assert spent == Decimal('30000.00')
|
||||
remaining = Decimal(response.data['remaining_amount'])
|
||||
assert remaining == Decimal('70000.00')
|
||||
assert 'reserved_amount' in response.data
|
||||
assert 'available_amount' in response.data
|
||||
|
||||
def test_my_department_no_department(self):
|
||||
"""Тест: 404 если пользователь не в отделе."""
|
||||
user_no_dept = self.user_factory(
|
||||
email='nodept@test.com',
|
||||
first_name='No',
|
||||
last_name='Dept',
|
||||
)
|
||||
self.client.force_authenticate(user=user_no_dept)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_my_department_no_budget(self):
|
||||
"""Тест: 404 если нет бюджета на текущий квартал."""
|
||||
# Удаляем бюджет
|
||||
self.budget.delete()
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcurementStatsEndpoints:
|
||||
"""Тесты для /stats/ endpoints."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(
|
||||
self, api_client, department_factory, user_factory, link_factory
|
||||
):
|
||||
"""Настройка тестовых данных."""
|
||||
self.client = api_client
|
||||
|
||||
self.department = department_factory(name='IT Stats Test')
|
||||
|
||||
self.user = user_factory(
|
||||
email='statsuser@test.com',
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
)
|
||||
link_factory(self.user, self.department, is_active=True)
|
||||
|
||||
self.staff_user = user_factory(
|
||||
email='statsstaff@test.com',
|
||||
first_name='Staff',
|
||||
last_name='User',
|
||||
)
|
||||
self.staff_user.is_staff = True
|
||||
self.staff_user.save()
|
||||
|
||||
# Создаём несколько заявок
|
||||
for i in range(3):
|
||||
ProcurementRequest.objects.create(
|
||||
title=f'Заявка {i+1}',
|
||||
description='Описание',
|
||||
department=self.department,
|
||||
requestor=self.user,
|
||||
status=ProcurementStatus.PENDING,
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
estimated_cost=Decimal('5000.00'),
|
||||
)
|
||||
|
||||
def test_stats_overview(self):
|
||||
"""Тест: получение общей статистики."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/overview/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'total_requests' in response.data
|
||||
assert 'pending_requests' in response.data
|
||||
assert 'by_status' in response.data
|
||||
assert 'by_urgency' in response.data
|
||||
|
||||
def test_stats_by_department_staff(self):
|
||||
"""Тест: статистика по отделам для staff."""
|
||||
self.client.force_authenticate(user=self.staff_user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
|
||||
def test_stats_by_department_regular_user(self):
|
||||
"""Тест: 403 для обычного пользователя."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_stats_by_department_head(self):
|
||||
"""Тест: руководитель отдела видит статистику своих отделов."""
|
||||
# Делаем пользователя руководителем
|
||||
self.department.head = self.user
|
||||
self.department.save()
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
assert response.data[0]['department']['id'] == self.department.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBudgetAlerts:
|
||||
"""Тесты для алертов бюджета."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(
|
||||
self, api_client, department_factory, user_factory, link_factory
|
||||
):
|
||||
"""Настройка тестовых данных."""
|
||||
self.client = api_client
|
||||
|
||||
self.department = department_factory(name='IT Alerts Test')
|
||||
|
||||
now = timezone.now()
|
||||
self.quarter = (now.month - 1) // 3 + 1
|
||||
|
||||
# Бюджет почти исчерпан (95% использовано)
|
||||
self.budget = Budget.objects.create(
|
||||
department=self.department,
|
||||
year=now.year,
|
||||
quarter=self.quarter,
|
||||
allocated_amount=Decimal('100000.00'),
|
||||
spent_amount=Decimal('95000.00'), # 95% использовано
|
||||
)
|
||||
|
||||
self.requestor = user_factory(
|
||||
email='alertsrequestor@test.com',
|
||||
first_name='Requestor',
|
||||
last_name='User',
|
||||
)
|
||||
link_factory(self.requestor, self.department, is_active=True)
|
||||
|
||||
# Руководитель отдела
|
||||
self.dept_head = user_factory(
|
||||
email='alertshead@test.com',
|
||||
first_name='Department',
|
||||
last_name='Head',
|
||||
)
|
||||
link_factory(self.dept_head, self.department, is_active=True)
|
||||
self.department.head = self.dept_head
|
||||
self.department.save()
|
||||
|
||||
# Даём права на approve
|
||||
perm = Permission.objects.get(codename='approve_procurementrequest')
|
||||
self.dept_head.user_permissions.add(perm)
|
||||
|
||||
def test_budget_utilization_above_90(self):
|
||||
"""Тест: бюджет используется более чем на 90%."""
|
||||
assert self.budget.utilization_percentage == Decimal('95.00')
|
||||
assert self.budget.remaining_amount == Decimal('5000.00')
|
||||
Loading…
x
Reference in New Issue
Block a user