From 69402567e39cbed564037d46298971199cdb48ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 9 Dec 2025 10:19:24 +0300 Subject: [PATCH] feat(procurement): add budget control and alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/procurement/models.py | 25 ++ backend/procurement/serializers.py | 32 +++ backend/procurement/urls.py | 6 + backend/procurement/views.py | 233 +++++++++++++++++- backend/tests/procurement/test_budget.py | 300 +++++++++++++++++++++++ 5 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 backend/tests/procurement/test_budget.py diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 38d036c3..aa1d3737 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -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): """Процент использования бюджета.""" diff --git a/backend/procurement/serializers.py b/backend/procurement/serializers.py index 539cd375..b60194b1 100644 --- a/backend/procurement/serializers.py +++ b/backend/procurement/serializers.py @@ -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): """Сериализатор для поставщиков.""" diff --git a/backend/procurement/urls.py b/backend/procurement/urls.py index 4f0ad19f..fbaf1097 100644 --- a/backend/procurement/urls.py +++ b/backend/procurement/urls.py @@ -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)), diff --git a/backend/procurement/views.py b/backend/procurement/views.py index 884e3823..b2907a67 100644 --- a/backend/procurement/views.py +++ b/backend/procurement/views.py @@ -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) + diff --git a/backend/tests/procurement/test_budget.py b/backend/tests/procurement/test_budget.py new file mode 100644 index 00000000..02be0a04 --- /dev/null +++ b/backend/tests/procurement/test_budget.py @@ -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')