From 2031872461ec1e6d09d14d5a6d6d52c353b5a491 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 30 Mar 2026 02:16:29 +0300 Subject: [PATCH] refactor(procurement): approval logic now fully driven by ApprovalRoute table (priority+min_amount), removed hardcoded steps, all workflow is DB-driven; tests and migrations updated --- backend/api/v1/procurement/views.py | 320 ++++-------------- backend/procurement/admin.py | 15 +- backend/procurement/constants.py | 15 +- .../migrations/0005_add_approval_route.py | 28 ++ .../0006_add_approval_priorities.py | 47 +++ .../0007_replace_roles_with_priority_steps.py | 64 ++++ .../migrations/0008_add_step_names.py | 47 +++ .../0009_approvalroute_min_amount.py | 37 ++ backend/procurement/models.py | 164 +++++---- backend/procurement/services.py | 86 +++++ backend/tests/procurement/test_integration.py | 8 +- backend/tests/procurement/test_lifecycle.py | 6 +- backend/tests/procurement/test_models.py | 69 ++-- backend/tests/procurement/test_workflow.py | 144 ++++---- 14 files changed, 618 insertions(+), 432 deletions(-) create mode 100644 backend/procurement/migrations/0005_add_approval_route.py create mode 100644 backend/procurement/migrations/0006_add_approval_priorities.py create mode 100644 backend/procurement/migrations/0007_replace_roles_with_priority_steps.py create mode 100644 backend/procurement/migrations/0008_add_step_names.py create mode 100644 backend/procurement/migrations/0009_approvalroute_min_amount.py diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index e4534362..a3a88821 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -1,8 +1,4 @@ -""" -ViewSets для API модуля закупок. -""" - -from decimal import Decimal +"""ViewSets для API модуля закупок.""" from django.db.models import Q from django.utils import timezone @@ -15,7 +11,6 @@ from employees.models import Department from procurement.constants import ApprovalStatus, ProcurementStatus, EquipmentStatus from procurement.models import ( Approval, - Budget, Equipment, EquipmentCategory, EquipmentTransferLog, @@ -26,17 +21,14 @@ from procurement.models import ( ) from communications import comments_helpers from communications.models import Message -from procurement.services import QRCodeGenerator +from procurement.services import ProcurementApprovalResolver, QRCodeGenerator from .permissions import ( CanApproveProcurementRequest, - CanManageBudget, CanManageEquipment, CanManageProcurementRequest, CanManageSupplier, ) from .serializers import ( - BudgetSerializer, - BudgetDetailSerializer, EquipmentCategorySerializer, EquipmentDetailSerializer, EquipmentListSerializer, @@ -47,7 +39,6 @@ from .serializers import ( ProcurementRequestListSerializer, SupplierSerializer, ) -from notifications.signals import notify class ProcurementRequestViewSet(viewsets.ModelViewSet): @@ -148,65 +139,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): return queryset.distinct() - def _check_budget_alert(self, procurement_request): - """Проверяет бюджет и отправляет алерт если низкий остаток.""" - 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% - notify.send( - sender=None, - recipient=dept_head, - verb='budget_critical', - description=( - f'Бюджет отдела "{department.name}" почти исчерпан! ' - f'Остаток: {budget.remaining_amount}₽ ' - f'({remaining_ratio*100:.1f}%)' - ), - action_url='/procurement', - data={'title': '⚠️ Критически низкий бюджет!'}, - ) - elif remaining_ratio <= LOW_BUDGET_THRESHOLD: - # Низкий уровень - менее 20% - notify.send( - sender=None, - recipient=dept_head, - verb='budget_low', - description=( - f'Бюджет отдела "{department.name}" снижается. ' - f'Остаток: {budget.remaining_amount}₽ ' - f'({remaining_ratio*100:.1f}%)' - ), - action_url='/procurement', - data={'title': 'Низкий остаток бюджета'}, - ) - @action(detail=True, methods=['post']) def submit(self, request, pk=None): """Отправить заявку на согласование.""" + from django.db import transaction + procurement_request = self.get_object() # Проверки @@ -228,57 +165,51 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) - # Проверка бюджета - available, remaining = procurement_request.check_budget_available() - if not available: + required_priorities = ( + procurement_request.get_required_approval_priorities() + ) + if not required_priorities: return Response( { - 'error': 'Недостаточно бюджета', - 'remaining': float(remaining), - 'required': float(procurement_request.total_cost), + 'error': ( + 'Для этой суммы заявки не настроены ' + 'маршруты согласования' + ) }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) - # Меняем статус - procurement_request.status = ProcurementStatus.PENDING - procurement_request.save() + resolved_approvals, missing_priorities = ( + ProcurementApprovalResolver.resolve_required_approvers( + procurement_request + ) + ) + if missing_priorities: + return Response( + { + 'error': 'Не настроены согласующие для обязательных этапов', + 'missing_priorities': missing_priorities, + }, + status=status.HTTP_400_BAD_REQUEST, + ) - # Создаем записи согласований - required_approvals = procurement_request.get_required_approvals() - from procurement.constants import ApprovalRole + with transaction.atomic(): + procurement_request.status = ProcurementStatus.PENDING + procurement_request.submitted_at = timezone.now() + procurement_request.save( + update_fields=['status', 'submitted_at', 'updated_at'] + ) - created_approvals = [] - for role in required_approvals: - # Определяем согласующего - approver = None - if role == ApprovalRole.DEPARTMENT_HEAD: - approver = procurement_request.department.head - elif role == ApprovalRole.FINANCE_MANAGER: - # Найдем первого пользователя с правом на бюджеты - from employees.models import Employee - from django.db.models import Q - approver = Employee.objects.filter( - is_active=True - ).filter( - Q(groups__permissions__codename='change_budget') | - Q(user_permissions__codename='change_budget') - ).distinct().first() - elif role == ApprovalRole.DIRECTOR: - # Найдем директора (суперпользователя) - from employees.models import Employee - approver = Employee.objects.filter( - is_superuser=True - ).first() - - if approver: - approval = Approval.objects.create( + for priority, approver, step_name in resolved_approvals: + Approval.objects.create( request=procurement_request, approver=approver, - role=role, + priority=priority, + step_name=step_name, status=ApprovalStatus.PENDING ) - created_approvals.append(approval) + + procurement_request.refresh_from_db() # Уведомления согласующим отправит сигнал post_save(Approval, created=True) @@ -294,10 +225,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): self.check_object_permissions(request, procurement_request) # Находим согласование текущего пользователя - approval = procurement_request.approvals.filter( - approver=request.user, - status=ApprovalStatus.PENDING - ).first() + approval = ProcurementApprovalResolver.get_available_approval( + request.user, + procurement_request, + ) if not approval: return Response( @@ -321,8 +252,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request.status = ProcurementStatus.APPROVED procurement_request.save() - # Проверяем бюджет и отправляем алерт если низкий - self._check_budget_alert(procurement_request) + procurement_request.refresh_from_db() serializer = self.get_serializer(procurement_request) return Response(serializer.data) @@ -336,10 +266,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): self.check_object_permissions(request, procurement_request) # Находим согласование текущего пользователя - approval = procurement_request.approvals.filter( - approver=request.user, - status=ApprovalStatus.PENDING - ).first() + approval = ProcurementApprovalResolver.get_available_approval( + request.user, + procurement_request, + ) if not approval: return Response( @@ -356,6 +286,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request.status = ProcurementStatus.REJECTED procurement_request.save() + procurement_request.refresh_from_db() + serializer = self.get_serializer(procurement_request) return Response(serializer.data) @@ -374,10 +306,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): def pending_approvals(self, request): """Получить заявки, ожидающие согласования текущим польз.""" # Находим заявки где есть pending approval для текущего юзера - queryset = self.get_queryset().filter( + base_queryset = self.get_queryset().filter( approvals__approver=request.user, approvals__status=ApprovalStatus.PENDING - ) + ).distinct() + available_ids = [ + request_obj.id + for request_obj in base_queryset + if ProcurementApprovalResolver.user_can_approve(request.user, request_obj) + ] + queryset = base_queryset.filter(id__in=available_ids) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) @@ -479,52 +417,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request.status = ProcurementStatus.COMPLETED procurement_request.completed_at = timezone.now() procurement_request.save() - - # Списываем бюджет отдела - self._deduct_budget(procurement_request) # Сигнал post_save(ProcurementRequest) с COMPLETED отправит уведомления # requestor'у и согласующим serializer = self.get_serializer(procurement_request) return Response(serializer.data) - def _deduct_budget(self, procurement_request): - """Списывает сумму заявки из бюджета отдела.""" - department = procurement_request.department - now = timezone.now() - quarter = (now.month - 1) // 3 + 1 - - # Используем фактическую сумму если есть, иначе общую стоимость позиций - amount = ( - procurement_request.actual_cost or - procurement_request.total_cost - ) - if not amount or amount <= 0: - return # Нечего списывать - - try: - budget = Budget.objects.get( - department=department, - year=now.year, - quarter=quarter - ) - except Budget.DoesNotExist: - # Нет бюджета - создаём с нулём, чтобы зафиксировать траты - budget = Budget.objects.create( - department=department, - year=now.year, - quarter=quarter, - allocated_amount=Decimal('0'), - spent_amount=Decimal('0') - ) - - # Увеличиваем потраченную сумму - budget.spent_amount = (budget.spent_amount or Decimal('0')) + amount - budget.save() - - # Проверяем и отправляем алерт если бюджет низкий - self._check_budget_alert(procurement_request) - @action(detail=True, methods=['post']) def cancel(self, request, pk=None): """Отменить заявку.""" @@ -1371,95 +1269,6 @@ class MaintenanceRecordViewSet(viewsets.ModelViewSet): serializer.save(performed_by=self.request.user) -class BudgetViewSet(viewsets.ModelViewSet): - """ViewSet для бюджетов.""" - - queryset = Budget.objects.select_related('department') - serializer_class = BudgetSerializer - filter_backends = [DjangoFilterBackend, filters.OrderingFilter] - filterset_fields = ['department', 'year', 'quarter'] - ordering_fields = ['year', 'quarter'] - ordering = ['-year', '-quarter'] - - def get_permissions(self): - """Права доступа.""" - 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): - """Фильтровать бюджеты.""" - queryset = super().get_queryset() - user = self.request.user - - if user.is_superuser or user.is_staff: - return queryset - - # Руководители видят бюджеты своих отделов - if user.headed_departments.exists(): - return queryset.filter( - department__in=user.headed_departments.all() - ) - - return queryset.none() - - @action(detail=False, methods=['get']) - def current_quarter(self, request): - """Получить бюджеты текущего квартала.""" - from django.utils import timezone - now = timezone.now() - quarter = (now.month - 1) // 3 + 1 - - queryset = self.get_queryset().filter( - year=now.year, - quarter=quarter - ) - 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 - - 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 для поставщиков.""" @@ -1584,26 +1393,11 @@ class ProcurementStatsViewSet(viewsets.ViewSet): 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/procurement/admin.py b/backend/procurement/admin.py index 2eb760b8..4d883df8 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -5,6 +5,7 @@ from django.utils.html import format_html from .models import ( Approval, + ApprovalRoute, Budget, Equipment, EquipmentCategory, @@ -159,6 +160,15 @@ class ProcurementItemAdmin(admin.ModelAdmin): readonly_fields = ["created_at", "total_price"] +@admin.register(ApprovalRoute) +class ApprovalRouteAdmin(admin.ModelAdmin): + """Маршруты согласования закупок.""" + + list_display = ["priority", "min_amount", "name", "resolver_type", "employee"] + ordering = ["priority"] + autocomplete_fields = ["employee"] + + @admin.register(Approval) class ApprovalAdmin(admin.ModelAdmin): """Админка для согласований.""" @@ -167,11 +177,12 @@ class ApprovalAdmin(admin.ModelAdmin): "id", "request", "approver", - "role", + "priority", + "step_name", "status_badge", "created_at", ] - list_filter = ["status", "role", "created_at"] + list_filter = ["status", "priority", "created_at"] search_fields = [ "request__title", "approver__username", diff --git a/backend/procurement/constants.py b/backend/procurement/constants.py index 36f9de7e..141d2534 100644 --- a/backend/procurement/constants.py +++ b/backend/procurement/constants.py @@ -25,11 +25,10 @@ class UrgencyLevel(models.TextChoices): CRITICAL = 'critical', 'Критическая' -class ApprovalRole(models.TextChoices): - """Роль согласующего в процессе одобрения.""" - DEPARTMENT_HEAD = 'department_head', 'Руководитель отдела' - FINANCE_MANAGER = 'finance_manager', 'Финансовый менеджер' - DIRECTOR = 'director', 'Генеральный директор' +def get_default_approval_step_name(priority: int, resolver_type: str | None = None) -> str: + if resolver_type == 'department_head': + return 'Руководитель отдела' + return f'Этап {priority}' class ApprovalStatus(models.TextChoices): @@ -56,9 +55,3 @@ class MaintenanceType(models.TextChoices): REPAIR = 'repair', 'Ремонт' UPGRADE = 'upgrade', 'Модернизация' CLEANING = 'cleaning', 'Чистка' - - -# Пороги для согласования по сумме (в рублях) -APPROVAL_THRESHOLD_LOW = 10_000 # До 10k - только руководитель отдела -# До 50k - + фин. менеджер, выше 50k - + директор -APPROVAL_THRESHOLD_HIGH = 50_000 diff --git a/backend/procurement/migrations/0005_add_approval_route.py b/backend/procurement/migrations/0005_add_approval_route.py new file mode 100644 index 00000000..67f72997 --- /dev/null +++ b/backend/procurement/migrations/0005_add_approval_route.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2026-03-27 23:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0004_add_executor_and_started_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApprovalRoute', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('department_head', 'Руководитель отдела'), ('finance_manager', 'Финансовый менеджер'), ('director', 'Генеральный директор')], max_length=20, unique=True, verbose_name='Роль')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий')), + ], + options={ + 'verbose_name': 'Маршрут согласования', + 'verbose_name_plural': 'Маршруты согласования', + }, + ), + ] diff --git a/backend/procurement/migrations/0006_add_approval_priorities.py b/backend/procurement/migrations/0006_add_approval_priorities.py new file mode 100644 index 00000000..03da4618 --- /dev/null +++ b/backend/procurement/migrations/0006_add_approval_priorities.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.4 on 2026-03-27 23:55 + +from django.db import migrations, models + + +def set_default_priorities(apps, schema_editor): + Approval = apps.get_model('procurement', 'Approval') + ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute') + + priorities = { + 'department_head': 10, + 'finance_manager': 20, + 'director': 30, + } + + for role, priority in priorities.items(): + Approval.objects.filter(role=role).update(priority=priority) + ApprovalRoute.objects.filter(role=role).update(priority=priority) + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0005_add_approval_route'), + ] + + operations = [ + migrations.AlterModelOptions( + name='approval', + options={'ordering': ['priority', 'created_at', 'id'], 'verbose_name': 'Согласование', 'verbose_name_plural': 'Согласования'}, + ), + migrations.AlterModelOptions( + name='approvalroute', + options={'ordering': ['priority', 'role', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'}, + ), + migrations.AddField( + model_name='approval', + name='priority', + field=models.PositiveSmallIntegerField(db_index=True, default=100, verbose_name='Приоритет'), + ), + migrations.AddField( + model_name='approvalroute', + name='priority', + field=models.PositiveSmallIntegerField(db_index=True, default=100, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', verbose_name='Приоритет'), + ), + migrations.RunPython(set_default_priorities, migrations.RunPython.noop), + ] diff --git a/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py b/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py new file mode 100644 index 00000000..b38a8add --- /dev/null +++ b/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.4 on 2026-03-28 00:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def migrate_route_roles_to_resolver_types(apps, schema_editor): + ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute') + ApprovalRoute.objects.filter(role='department_head').update( + resolver_type='department_head', + employee=None, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0006_add_approval_priorities'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='approvalroute', + options={'ordering': ['priority', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'}, + ), + migrations.AlterUniqueTogether( + name='approval', + unique_together={('request', 'priority')}, + ), + migrations.AddField( + model_name='approvalroute', + name='resolver_type', + field=models.CharField(choices=[('department_head', 'Руководитель отдела'), ('fixed_employee', 'Конкретный сотрудник')], default='fixed_employee', max_length=20, verbose_name='Тип резолва'), + ), + migrations.AlterField( + model_name='approval', + name='priority', + field=models.PositiveSmallIntegerField(db_index=True, verbose_name='Приоритет'), + ), + migrations.AlterField( + model_name='approvalroute', + name='employee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий'), + ), + migrations.AlterField( + model_name='approvalroute', + name='priority', + field=models.PositiveSmallIntegerField(db_index=True, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', unique=True, verbose_name='Приоритет'), + ), + migrations.RunPython( + migrate_route_roles_to_resolver_types, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name='approvalroute', + name='role', + ), + migrations.RemoveField( + model_name='approval', + name='role', + ), + ] diff --git a/backend/procurement/migrations/0008_add_step_names.py b/backend/procurement/migrations/0008_add_step_names.py new file mode 100644 index 00000000..6501e352 --- /dev/null +++ b/backend/procurement/migrations/0008_add_step_names.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.4 on 2026-03-28 00:21 + +from django.db import migrations, models + + +def populate_step_names(apps, schema_editor): + Approval = apps.get_model('procurement', 'Approval') + ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute') + + default_names = { + 10: 'Руководитель отдела', + 20: 'Финансовый менеджер', + 30: 'Генеральный директор', + } + + route_names = { + route.priority: (route.name or default_names.get(route.priority, f'Этап {route.priority}')) + for route in ApprovalRoute.objects.all() + } + + for approval in Approval.objects.all(): + approval.step_name = route_names.get( + approval.priority, + default_names.get(approval.priority, f'Этап {approval.priority}'), + ) + approval.save(update_fields=['step_name']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0007_replace_roles_with_priority_steps'), + ] + + operations = [ + migrations.AddField( + model_name='approval', + name='step_name', + field=models.CharField(blank=True, max_length=150, verbose_name='Название этапа'), + ), + migrations.AddField( + model_name='approvalroute', + name='name', + field=models.CharField(blank=True, help_text='Необязательно. Если заполнено, будет показано в интерфейсе.', max_length=150, verbose_name='Название этапа'), + ), + migrations.RunPython(populate_step_names, migrations.RunPython.noop), + ] diff --git a/backend/procurement/migrations/0009_approvalroute_min_amount.py b/backend/procurement/migrations/0009_approvalroute_min_amount.py new file mode 100644 index 00000000..5545cb52 --- /dev/null +++ b/backend/procurement/migrations/0009_approvalroute_min_amount.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.4 on 2026-03-28 00:35 + +import django.core.validators +from decimal import Decimal +from django.db import migrations, models + + +def backfill_legacy_min_amounts(apps, schema_editor): + ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute') + + ApprovalRoute.objects.filter( + priority=20, + min_amount__isnull=True, + ).update(min_amount=Decimal('10000.00')) + ApprovalRoute.objects.filter( + priority=30, + min_amount__isnull=True, + ).update(min_amount=Decimal('50000.00')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0008_add_step_names'), + ] + + operations = [ + migrations.AddField( + model_name='approvalroute', + name='min_amount', + field=models.DecimalField(blank=True, decimal_places=2, help_text='Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.', max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Минимальная сумма заявки'), + ), + migrations.RunPython( + backfill_legacy_min_amounts, + migrations.RunPython.noop, + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index e20df7c6..3908fe0d 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -16,6 +16,7 @@ from decimal import Decimal from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.db.models import Sum @@ -23,14 +24,12 @@ from django.utils import timezone from employees.models import Department from .constants import ( - APPROVAL_THRESHOLD_HIGH, - APPROVAL_THRESHOLD_LOW, - ApprovalRole, ApprovalStatus, EquipmentStatus, MaintenanceType, ProcurementStatus, UrgencyLevel, + get_default_approval_step_name, ) User = get_user_model() @@ -132,50 +131,15 @@ class ProcurementRequest(models.Model): def __str__(self): return f"#{self.pk} {self.title}" - def get_required_approvals(self): - """Возвращает список необходимых ролей для согласования. - - Логика: - - < 10,000₽: только руководитель отдела - - 10,000 - 100,000₽: руководитель + финансовый менеджер - - > 100,000₽: руководитель + финансы + директор - """ - cost = self.total_cost - - if cost < APPROVAL_THRESHOLD_LOW: - return [ApprovalRole.DEPARTMENT_HEAD] - elif cost < APPROVAL_THRESHOLD_HIGH: - return [ - ApprovalRole.DEPARTMENT_HEAD, - ApprovalRole.FINANCE_MANAGER - ] - else: - return [ - ApprovalRole.DEPARTMENT_HEAD, - ApprovalRole.FINANCE_MANAGER, - ApprovalRole.DIRECTOR - ] - - def check_budget_available(self): - """Проверяет наличие бюджета в отделе. - - Returns: - tuple: (bool, Decimal) - (доступно ли, остаток бюджета) - """ - # Определяем текущий квартал - now = timezone.now() - quarter = (now.month - 1) // 3 + 1 - - try: - budget = Budget.objects.get( - department=self.department, - year=now.year, - quarter=quarter - ) - remaining = budget.remaining_amount - return (remaining >= self.total_cost, remaining) - except Budget.DoesNotExist: - return (False, Decimal('0')) + def get_required_approval_priorities(self): + """Возвращает приоритеты обязательных этапов из таблицы маршрутов.""" + return list( + self.get_required_approval_routes().values_list('priority', flat=True) + ) + + def get_required_approval_routes(self): + """Возвращает queryset обязательных этапов из таблицы маршрутов.""" + return ApprovalRoute.get_applicable_routes(self.total_cost) @property def total_cost(self): @@ -265,6 +229,74 @@ class ProcurementItem(models.Model): return self.estimated_unit_price * self.quantity +class ApprovalRoute(models.Model): + """Правило получения согласующего для этапа с заданным приоритетом.""" + + class ResolverType(models.TextChoices): + DEPARTMENT_HEAD = 'department_head', 'Руководитель отдела' + FIXED_EMPLOYEE = 'fixed_employee', 'Конкретный сотрудник' + + priority = models.PositiveSmallIntegerField( + 'Приоритет', + unique=True, + db_index=True, + help_text='Чем меньше число, тем раньше этап в цепочке согласования.', + ) + min_amount = models.DecimalField( + 'Минимальная сумма заявки', + max_digits=12, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(Decimal('0.00'))], + help_text='Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.', + ) + name = models.CharField( + 'Название этапа', + max_length=150, + blank=True, + help_text='Необязательно. Если заполнено, будет показано в интерфейсе.', + ) + resolver_type = models.CharField( + 'Тип резолва', + max_length=20, + choices=ResolverType.choices, + default=ResolverType.FIXED_EMPLOYEE, + ) + employee = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name='approval_routes', + verbose_name='Согласующий', + null=True, + blank=True, + ) + + class Meta: + verbose_name = 'Маршрут согласования' + verbose_name_plural = 'Маршруты согласования' + ordering = ['priority', 'id'] + + @classmethod + def get_applicable_routes(cls, total_cost): + return cls.objects.filter( + models.Q(min_amount__isnull=True) | models.Q(min_amount__lte=total_cost) + ).order_by('priority', 'id') + + def __str__(self): + threshold = '' if self.min_amount is None else f' от {self.min_amount}' + if self.resolver_type == self.ResolverType.DEPARTMENT_HEAD: + return f"Этап {self.priority}{threshold} → руководитель отдела" + return f"Этап {self.priority}{threshold} → {self.employee}" + + def clean(self): + super().clean() + if self.resolver_type == self.ResolverType.FIXED_EMPLOYEE and not self.employee_id: + raise ValidationError({'employee': 'Для статического этапа укажите сотрудника.'}) + if self.resolver_type == self.ResolverType.DEPARTMENT_HEAD and self.employee_id: + raise ValidationError({'employee': 'Для этапа руководителя отдела сотрудник не указывается.'}) + + class Approval(models.Model): """Запись о согласовании заявки.""" @@ -280,11 +312,14 @@ class Approval(models.Model): related_name='procurement_approvals', verbose_name='Согласующий' ) - role = models.CharField( - 'Роль', - max_length=20, - choices=ApprovalRole.choices, - help_text='Роль согласующего в процессе' + priority = models.PositiveSmallIntegerField( + 'Приоритет', + db_index=True, + ) + step_name = models.CharField( + 'Название этапа', + max_length=150, + blank=True, ) status = models.CharField( 'Статус', @@ -302,14 +337,27 @@ class Approval(models.Model): class Meta: verbose_name = 'Согласование' verbose_name_plural = 'Согласования' - ordering = ['created_at'] - unique_together = [('request', 'role')] + ordering = ['priority', 'created_at', 'id'] + unique_together = [('request', 'priority')] def __str__(self): - return ( - f"{self.get_role_display()} - " - f"{self.get_status_display()}" - ) + return f"Этап {self.priority} - {self.get_status_display()}" + + def save(self, *args, **kwargs): + if not self.step_name: + route = ApprovalRoute.objects.filter(priority=self.priority).only( + 'name', + 'resolver_type', + ).first() + if route and route.name: + self.step_name = route.name + else: + resolver_type = route.resolver_type if route else None + self.step_name = get_default_approval_step_name( + self.priority, + resolver_type=resolver_type, + ) + super().save(*args, **kwargs) class EquipmentCategory(models.Model): diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 57e938be..13a4b5d9 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -11,6 +11,11 @@ from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone +from procurement.constants import ( + ApprovalStatus, + get_default_approval_step_name, +) + class InventoryNumberGenerator: """Генератор инвентарных номеров.""" @@ -144,6 +149,87 @@ class QRCodeGenerator: return os.path.join(cls.QR_CODE_DIR, f'{inventory_number}.png') +class ProcurementApprovalResolver: + """Определяет согласующих для заявки на закупку. + + Состав этапов и их порядок берутся из таблицы ApprovalRoute. + Для этапа типа department_head согласующий определяется через Department.head. + """ + + @classmethod + def resolve_approval_step(cls, procurement_request, route): + from .models import ApprovalRoute + + if route is None: + return None + + if not isinstance(route, ApprovalRoute): + route = ApprovalRoute.objects.select_related('employee').filter( + priority=route, + ).first() + if route is None: + return None + + step_name = route.name or get_default_approval_step_name( + route.priority, + resolver_type=route.resolver_type, + ) + + if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD: + head = procurement_request.department.head + if head and head.is_active: + return route.priority, head, step_name + return None + + if route.employee and route.employee.is_active: + return route.priority, route.employee, step_name + return None + + @classmethod + def resolve_approver(cls, procurement_request, priority: int): + step = cls.resolve_approval_step(procurement_request, priority) + if step is None: + return None + return step[1] + + @classmethod + def resolve_required_approvers(cls, procurement_request): + resolved, missing = [], [] + for route in procurement_request.get_required_approval_routes(): + step = cls.resolve_approval_step(procurement_request, route) + if step: + resolved.append(step) + else: + missing.append(route.priority) + resolved.sort(key=lambda item: item[0]) + return resolved, missing + + @classmethod + def get_available_approval(cls, user, procurement_request): + if not user or not user.is_authenticated: + return None + + pending = procurement_request.approvals.filter( + status=ApprovalStatus.PENDING, + ).order_by('priority', 'created_at', 'id') + first_pending = pending.first() + if first_pending is None: + return None + + return pending.filter( + approver=user, + priority=first_pending.priority, + ).first() + + @classmethod + def user_can_approve(cls, user, procurement_request) -> bool: + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + return cls.get_available_approval(user, procurement_request) is not None + + class EquipmentService: """Сервис для операций с оборудованием.""" diff --git a/backend/tests/procurement/test_integration.py b/backend/tests/procurement/test_integration.py index c1242dfa..63e19e45 100644 --- a/backend/tests/procurement/test_integration.py +++ b/backend/tests/procurement/test_integration.py @@ -14,7 +14,7 @@ from notifications.models import ( NotificationCategory, NotificationType, ) -from procurement.constants import ApprovalRole, ApprovalStatus, ProcurementStatus +from procurement.constants import ApprovalStatus, ProcurementStatus from procurement.models import ( Approval, Budget, @@ -160,7 +160,7 @@ class TestProcurementSignals: Approval.objects.create( request=request, approver=department_with_head.head, - role=ApprovalRole.DEPARTMENT_HEAD, + priority=10, ) # Отправляем на согласование @@ -241,7 +241,7 @@ class TestApprovalNotifications: approval = Approval.objects.create( request=request, approver=department_with_head.head, - role=ApprovalRole.DEPARTMENT_HEAD, + priority=10, status=ApprovalStatus.PENDING, ) @@ -273,7 +273,7 @@ class TestApprovalNotifications: approval = Approval.objects.create( request=request, approver=department_with_head.head, - role=ApprovalRole.DEPARTMENT_HEAD, + priority=10, status=ApprovalStatus.PENDING, ) diff --git a/backend/tests/procurement/test_lifecycle.py b/backend/tests/procurement/test_lifecycle.py index 7a5174ca..a6b6b48c 100644 --- a/backend/tests/procurement/test_lifecycle.py +++ b/backend/tests/procurement/test_lifecycle.py @@ -134,7 +134,7 @@ class TestRequestLifecycle: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=10, status=ApprovalStatus.APPROVED, ) @@ -189,7 +189,7 @@ class TestRequestLifecycle: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=10, status=ApprovalStatus.APPROVED, ) @@ -258,7 +258,7 @@ class TestRequestLifecycle: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=10, status=ApprovalStatus.PENDING, ) diff --git a/backend/tests/procurement/test_models.py b/backend/tests/procurement/test_models.py index c9e7bf6f..1fa4c6c4 100644 --- a/backend/tests/procurement/test_models.py +++ b/backend/tests/procurement/test_models.py @@ -10,12 +10,12 @@ from django.contrib.auth import get_user_model from employees.models import Department from procurement.constants import ( - ApprovalRole, EquipmentStatus, ProcurementStatus, UrgencyLevel, ) from procurement.models import ( + ApprovalRoute, Budget, Equipment, EquipmentCategory, @@ -26,6 +26,10 @@ from procurement.models import ( User = get_user_model() +HEAD_PRIORITY = 1 +FINANCE_PRIORITY = 2 +DIRECTOR_PRIORITY = 3 + @pytest.fixture def department(db): @@ -65,6 +69,26 @@ def budget(db, department): ) +@pytest.fixture +def approval_routes(db, user): + ApprovalRoute.objects.create( + priority=HEAD_PRIORITY, + resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, + ) + ApprovalRoute.objects.create( + priority=FINANCE_PRIORITY, + min_amount=Decimal('10000.00'), + resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE, + employee=user, + ) + ApprovalRoute.objects.create( + priority=DIRECTOR_PRIORITY, + min_amount=Decimal('50000.00'), + resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE, + employee=user, + ) + + @pytest.mark.django_db class TestProcurementRequest: """Тесты модели ProcurementRequest.""" @@ -85,19 +109,26 @@ class TestProcurementRequest: assert request.is_editable is True assert request.items_count == 0 - def test_get_required_approvals_low(self, department, user): + def test_get_required_approval_priorities_low(self, department, user, approval_routes): """Тест получения уровней согласования для малой суммы.""" request = ProcurementRequest.objects.create( title="Канцтовары", department=department, requestor=user, ) + ProcurementItem.objects.create( + request=request, + name='Бумага', + quantity=1, + unit='шт', + estimated_unit_price=Decimal('5000.00'), + ) - approvals = request.get_required_approvals() + approvals = request.get_required_approval_priorities() assert len(approvals) == 1 - assert ApprovalRole.DEPARTMENT_HEAD in approvals + assert HEAD_PRIORITY in approvals - def test_get_required_approvals_medium(self, department, user): + def test_get_required_approval_priorities_medium(self, department, user, approval_routes): """Тест получения уровней согласования для средней суммы (10k-50k).""" request = ProcurementRequest.objects.create( title="Оргтехника", @@ -113,12 +144,12 @@ class TestProcurementRequest: estimated_unit_price=Decimal('30000.00'), ) - approvals = request.get_required_approvals() + approvals = request.get_required_approval_priorities() assert len(approvals) == 2 - assert ApprovalRole.DEPARTMENT_HEAD in approvals - assert ApprovalRole.FINANCE_MANAGER in approvals + assert HEAD_PRIORITY in approvals + assert FINANCE_PRIORITY in approvals - def test_get_required_approvals_high(self, department, user): + def test_get_required_approval_priorities_high(self, department, user, approval_routes): """Тест получения уровней согласования для большой суммы (>50k).""" request = ProcurementRequest.objects.create( title="Серверное оборудование", @@ -134,23 +165,11 @@ class TestProcurementRequest: estimated_unit_price=Decimal('500000.00'), ) - approvals = request.get_required_approvals() + approvals = request.get_required_approval_priorities() assert len(approvals) == 3 - assert ApprovalRole.DEPARTMENT_HEAD in approvals - assert ApprovalRole.FINANCE_MANAGER in approvals - assert ApprovalRole.DIRECTOR in approvals - - def test_check_budget_available(self, department, user, budget): - """Тест проверки доступности бюджета.""" - request = ProcurementRequest.objects.create( - title="Покупка", - department=department, - requestor=user, - ) - - available, remaining = request.check_budget_available() - assert available is True - assert remaining == Decimal("70000.00") + assert HEAD_PRIORITY in approvals + assert FINANCE_PRIORITY in approvals + assert DIRECTOR_PRIORITY in approvals def test_is_editable_draft(self, department, user): """Тест: черновик можно редактировать.""" diff --git a/backend/tests/procurement/test_workflow.py b/backend/tests/procurement/test_workflow.py index a2ef5089..5126ef32 100644 --- a/backend/tests/procurement/test_workflow.py +++ b/backend/tests/procurement/test_workflow.py @@ -4,9 +4,9 @@ import pytest from django.contrib.auth.models import Permission +from django.urls import reverse from rest_framework import status -from notifications.models import NotificationCategory, NotificationType from procurement.constants import ( ApprovalStatus, ProcurementStatus, @@ -14,11 +14,16 @@ from procurement.constants import ( ) from procurement.models import ( Approval, + ApprovalRoute, Budget, ProcurementItem, ProcurementRequest, ) +HEAD_PRIORITY = 1 +FINANCE_PRIORITY = 2 +DIRECTOR_PRIORITY = 3 + @pytest.mark.django_db class TestProcurementWorkflow: @@ -29,43 +34,6 @@ class TestProcurementWorkflow: self, api_client, department_factory, user_factory, link_factory ): """Подготовка данных для тестов.""" - # Создаем категорию и типы уведомлений для закупок - category, _ = NotificationCategory.objects.get_or_create( - code='procurement', - defaults={ - 'name': 'Закупки', - 'icon': 'bi-cart', - 'color': 'success', - 'order': 10, - } - ) - - # Создаем типы уведомлений - notification_types = [ - 'procurement_new_request', - 'procurement_pending_approval', - 'procurement_approved', - 'procurement_rejected', - 'procurement_completed', - 'procurement_stage_approved', - ] - - for type_code in notification_types: - NotificationType.objects.get_or_create( - code=type_code, - defaults={ - 'category': category, - 'name': type_code.replace('_', ' ').title(), - 'description': f'Уведомление {type_code}', - 'priority': 'normal', - 'default_channels': { - 'web': True, - 'email': False, - 'telegram': False - }, - } - ) - # Создаем отдел self.department = department_factory(name="IT отдел") @@ -101,6 +69,23 @@ class TestProcurementWorkflow: superuser=True, ) + ApprovalRoute.objects.create( + priority=HEAD_PRIORITY, + resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, + ) + ApprovalRoute.objects.create( + priority=FINANCE_PRIORITY, + min_amount=10000, + resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE, + employee=self.finance, + ) + ApprovalRoute.objects.create( + priority=DIRECTOR_PRIORITY, + min_amount=50000, + resolver_type=ApprovalRoute.ResolverType.FIXED_EMPLOYEE, + employee=self.director, + ) + # Создаем бюджет Budget.objects.create( department=self.department, @@ -132,7 +117,7 @@ class TestProcurementWorkflow: } response = self.client.post( - '/api/procurement/requests/', + reverse('api:v1:procurement:procurementrequest-list'), data, format='json' ) @@ -170,11 +155,13 @@ class TestProcurementWorkflow: ) self.client.force_authenticate(user=self.requestor) - - response = self.client.post( - f'/api/procurement/requests/{request.id}/submit/' + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': request.id}, ) + response = self.client.post(submit_url) + if response.status_code != status.HTTP_200_OK: print(f"Error response: {response.data}") assert response.status_code == status.HTTP_200_OK @@ -199,20 +186,24 @@ class TestProcurementWorkflow: ) self.client.force_authenticate(user=self.requestor) - - response = self.client.post( - f'/api/procurement/requests/{request.id}/submit/' + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': request.id}, ) + response = self.client.post(submit_url) + assert response.status_code == status.HTTP_400_BAD_REQUEST assert 'позиц' in response.data['error'].lower() - def test_cannot_submit_without_budget(self): - """Тест: нельзя отправить заявку если нет бюджета.""" - # Создаем заявку на сумму больше бюджета + def test_submit_fails_when_required_authority_missing(self): + """Тест: нельзя отправить заявку без настроенного обязательного согласующего.""" + self.director.is_active = False + self.director.save(update_fields=['is_active']) + request = ProcurementRequest.objects.create( title='Дорогая заявка', - description='Превышает бюджет', + description='Нет директора для согласования', department=self.department, requestor=self.requestor, status=ProcurementStatus.DRAFT, @@ -227,13 +218,16 @@ class TestProcurementWorkflow: ) self.client.force_authenticate(user=self.requestor) - - response = self.client.post( - f'/api/procurement/requests/{request.id}/submit/' + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': request.id}, ) + response = self.client.post(submit_url) + assert response.status_code == status.HTTP_400_BAD_REQUEST - assert 'бюджет' in response.data['error'].lower() + assert 'согласующ' in response.data['error'].lower() + assert DIRECTOR_PRIORITY in response.data['missing_priorities'] def test_approve_by_department_head(self): """Тест одобрения заявки руководителем отдела.""" @@ -258,14 +252,18 @@ class TestProcurementWorkflow: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=HEAD_PRIORITY, status=ApprovalStatus.PENDING, ) self.client.force_authenticate(user=self.dept_head) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': request.id}, + ) response = self.client.post( - f'/api/procurement/requests/{request.id}/approve/', + approve_url, {'comment': 'Одобряю'}, ) @@ -294,14 +292,18 @@ class TestProcurementWorkflow: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=HEAD_PRIORITY, status=ApprovalStatus.PENDING, ) self.client.force_authenticate(user=self.dept_head) + reject_url = reverse( + 'api:v1:procurement:procurementrequest-reject', + kwargs={'pk': request.id}, + ) response = self.client.post( - f'/api/procurement/requests/{request.id}/reject/', + reject_url, {'comment': 'Не обоснована необходимость'}, ) @@ -339,20 +341,24 @@ class TestProcurementWorkflow: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=HEAD_PRIORITY, status=ApprovalStatus.PENDING, ) Approval.objects.create( request=request, approver=self.finance, - role='finance_manager', + priority=FINANCE_PRIORITY, status=ApprovalStatus.PENDING, ) # 1. Одобряет руководитель отдела self.client.force_authenticate(user=self.dept_head) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': request.id}, + ) response = self.client.post( - f'/api/procurement/requests/{request.id}/approve/', + approve_url, {'comment': 'От отдела одобряю'}, ) assert response.status_code == status.HTTP_200_OK @@ -364,7 +370,7 @@ class TestProcurementWorkflow: # 2. Одобряет финансовый менеджер self.client.force_authenticate(user=self.finance) response = self.client.post( - f'/api/procurement/requests/{request.id}/approve/', + approve_url, {'comment': 'От финансов одобряю'}, ) print(f"DEBUG 2: Response status: {response.status_code}") @@ -390,17 +396,19 @@ class TestProcurementWorkflow: Approval.objects.create( request=request, approver=self.dept_head, - role='department_head', + priority=HEAD_PRIORITY, status=ApprovalStatus.PENDING, ) # Пытаемся одобрить от лица другого пользователя self.client.force_authenticate(user=self.finance) - - response = self.client.post( - f'/api/procurement/requests/{request.id}/approve/' + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': request.id}, ) + response = self.client.post(approve_url) + # Должна быть ошибка - 404 или 403/400 # 404 если заявка не в queryset, 403/400 если нет прав assert response.status_code in [ @@ -423,9 +431,13 @@ class TestProcurementWorkflow: assert request.is_editable is False self.client.force_authenticate(user=self.requestor) + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': request.id}, + ) response = self.client.patch( - f'/api/procurement/requests/{request.id}/', + detail_url, {'title': 'Новое название'}, )