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:
admin i 2025-12-09 10:19:24 +03:00
parent 91fc46d6b9
commit 69402567e3
5 changed files with 592 additions and 4 deletions

View File

@ -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):
"""Процент использования бюджета."""

View File

@ -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):
"""Сериализатор для поставщиков."""

View File

@ -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)),

View File

@ -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)

View 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')