From af423d8d0ad8d21c12a5b280ab67f24d064d3fb6 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 02:55:26 +0300 Subject: [PATCH] feat(procurement): tighten approval flow and request actions --- backend/api/v1/procurement/permissions.py | 3 - backend/api/v1/procurement/serializers.py | 19 + backend/api/v1/procurement/views.py | 15 +- backend/procurement/notifications/handlers.py | 22 +- backend/procurement/notifications/signals.py | 10 +- backend/procurement/services.py | 2 - .../procurement/test_procurement_requests.py | 629 ++++++++++++++++++ frontend/next-env.d.ts | 2 +- frontend/src/app/equipment/page.tsx | 32 +- frontend/src/app/procurement/page.tsx | 242 ++++++- frontend/src/app/requests/page.tsx | 13 +- frontend/src/hooks/useProcurementPage.ts | 20 +- frontend/src/types/api.ts | 1 + 13 files changed, 918 insertions(+), 92 deletions(-) diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 682886d2..974c935c 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -86,9 +86,6 @@ class CanApproveProcurementRequest(permissions.BasePermission): if not request.user or not request.user.is_authenticated: return False - if request.user.is_superuser: - return True - return ProcurementApprovalResolver.user_can_approve(request.user, obj) diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 905a66ca..b8bd5e97 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -6,6 +6,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from procurement.constants import get_default_approval_step_name +from procurement.services import ProcurementApprovalResolver from procurement.models import ( Approval, Budget, @@ -123,6 +124,13 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): source="executor.get_full_name", read_only=True, allow_null=True ) comments_count = serializers.IntegerField(read_only=True, default=0) + can_current_user_approve = serializers.SerializerMethodField() + + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_approve(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_approve(user, obj) class Meta: model = ProcurementRequest @@ -142,6 +150,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "total_cost", "items_count", "comments_count", + "can_current_user_approve", "created_at", "submitted_at", "started_at", @@ -159,6 +168,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "items_count", "total_cost", "comments_count", + "can_current_user_approve", ] def update(self, instance, validated_data): @@ -204,6 +214,13 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): ) is_editable = serializers.BooleanField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) + can_current_user_approve = serializers.SerializerMethodField() + + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_approve(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_approve(user, obj) class Meta: model = ProcurementRequest @@ -229,6 +246,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "required_approval_priorities", "is_editable", "comments_count", + "can_current_user_approve", "created_at", "updated_at", "submitted_at", @@ -246,6 +264,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "total_cost", "executor_name", "comments_count", + "can_current_user_approve", ] diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 87facd5d..07f2a35e 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -357,12 +357,6 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) with transaction.atomic(): - procurement_request.status = ProcurementStatus.PENDING - procurement_request.submitted_at = timezone.now() - procurement_request.save( - update_fields=["status", "submitted_at", "updated_at"] - ) - for priority, approver, step_name in resolved_approvals: Approval.objects.create( request=procurement_request, @@ -372,10 +366,13 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=ApprovalStatus.PENDING, ) - procurement_request.refresh_from_db() + procurement_request.status = ProcurementStatus.PENDING + procurement_request.submitted_at = timezone.now() + procurement_request.save( + update_fields=["status", "submitted_at", "updated_at"] + ) - # Уведомления согласующим отправит сигнал post_save(Approval, - # created=True) + procurement_request.refresh_from_db() serializer = self.get_serializer(procurement_request) return Response(serializer.data) diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index ce46c4ec..1060819c 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -13,6 +13,19 @@ from .config import NotificationVerbs, MessageTemplates, ActionURLs logger = logging.getLogger(__name__) +def get_current_pending_approvals(request): + """Вернуть pending approvals только текущего этапа.""" + from ..constants import ApprovalStatus + + pending_approvals = request.approvals.filter( + status=ApprovalStatus.PENDING + ).order_by("priority", "created_at", "id") + first_pending = pending_approvals.first() + if first_pending is None: + return pending_approvals.none() + return pending_approvals.filter(priority=first_pending.priority) + + def notify_new_request(request): """ Уведомить о новой заявке на закупку. @@ -54,16 +67,12 @@ def notify_new_request(request): def notify_approvers(request): """ - Уведомить всех согласующих о необходимости согласования заявки. + Уведомить согласующих только текущего этапа. Args: request: Объект ProcurementRequest """ - from ..constants import ApprovalStatus - - pending_approvals = request.approvals.filter( - status=ApprovalStatus.PENDING - ) + pending_approvals = get_current_pending_approvals(request) if not pending_approvals.exists(): logger.warning( @@ -318,6 +327,7 @@ def notify_stage_approved(approval): notification_title, description ) + notify_approvers(approval.request) def notify_stage_rejected(approval): diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py index 4918e2f9..c068591f 100644 --- a/backend/procurement/notifications/signals.py +++ b/backend/procurement/notifications/signals.py @@ -105,17 +105,15 @@ def create_approval_notifications(sender, instance, created, **kwargs): Создает уведомления при создании или изменении согласования. Обрабатывает: - 1. Новое согласование - уведомление согласующему - 2. Одобрение - уведомление создателю заявки - 3. Отклонение - уведомление создателю заявки + 1. Одобрение - уведомление создателю заявки и следующему этапу + 2. Отклонение - уведомление создателю заявки """ try: approval = instance if created: - # Новое согласование создано - from .handlers import notify_approver - notify_approver(approval) + # Нотификацию при submit запускает сигнал заявки, когда уже + # созданы все Approval и можно определить только текущий этап. return # Проверяем изменение статуса согласования diff --git a/backend/procurement/services.py b/backend/procurement/services.py index ca5fc79b..c398c26f 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -202,8 +202,6 @@ class ProcurementApprovalResolver: 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 diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 2ac82f30..3d47a458 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -5,6 +5,7 @@ from decimal import Decimal import pytest +from django.contrib.auth.models import Permission from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -103,6 +104,47 @@ def staff_user(db): ) +@pytest.fixture +def superuser(db): + """Суперпользователь без назначения в текущем approval stage.""" + return Employee.objects.create_user( + email="superuser@example.com", + password="testpass123", + phone_number="+79996666666", + first_name="Супер", + last_name="Пользователь", + is_staff=True, + is_superuser=True, + is_active=True, + email_verified=True, + send_activation_email=False, + ) + + +@pytest.fixture +def approver_with_permission(db, department): + """Пользователь с модельным permission approve_procurementrequest.""" + employee = Employee.objects.create_user( + email="approver@example.com", + password="testpass123", + phone_number="+79997777777", + first_name="Разрешенный", + last_name="Согласующий", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=True, + ) + employee.user_permissions.add( + Permission.objects.get(codename="approve_procurementrequest") + ) + return employee + + @pytest.fixture def budget(db, department): """Бюджет отдела.""" @@ -498,6 +540,338 @@ class TestProcurementRequestWorkflow: employee=director, ) + def test_submit_notifies_only_current_stage_approver( + self, api_client, user, procurement_request, procurement_item, budget, monkeypatch + ): + """При submit уведомление должно уйти только текущему этапу.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + pending_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_pending_approval" + ] + assert pending_recipients == ["head@example.com"] + + def test_approve_notifies_only_next_stage_approver( + self, api_client, department_head, procurement_request, procurement_item, monkeypatch + ): + """После approve уведомление переходит только следующему этапу.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Руководитель отдела", + ) + Approval.objects.create( + request=procurement_request, + approver=Employee.objects.get(email="user@example.com"), + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Финансовый контроль", + ) + Approval.objects.create( + request=procurement_request, + approver=Employee.objects.get(email="director@example.com"), + priority=DIRECTOR_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Финальное одобрение", + ) + sent.clear() + + api_client.force_authenticate(user=department_head) + url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'comment': 'Одобрено'}) + assert response.status_code == status.HTTP_200_OK + + pending_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_pending_approval" + ] + assert pending_recipients == ["user@example.com"] + + def test_final_approve_does_not_notify_future_approvers( + self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch + ): + """На финальном approve не должно быть нового pending-уведомления.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=approver_with_permission, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Финальный этап", + ) + sent.clear() + + api_client.force_authenticate(user=approver_with_permission) + url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'comment': 'Финально одобрено'}) + assert response.status_code == status.HTTP_200_OK + + pending_notifications = [ + item for item in sent + if item["verb"] == "procurement_pending_approval" + ] + assert pending_notifications == [] + + def test_reject_notifies_requestor_without_next_stage_notification( + self, api_client, department_head, procurement_request, procurement_item, monkeypatch + ): + """Reject не должен уведомлять следующий этап, только создателя.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Руководитель отдела", + ) + Approval.objects.create( + request=procurement_request, + approver=Employee.objects.get(email="user@example.com"), + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Финансовый контроль", + ) + sent.clear() + + api_client.force_authenticate(user=department_head) + url = reverse( + 'api:v1:procurement:procurementrequest-reject', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'comment': 'Отклонено'}) + assert response.status_code == status.HTTP_200_OK + + verbs = [item["verb"] for item in sent] + recipients = [item["recipient"].email for item in sent] + assert "procurement_rejected" in verbs + assert procurement_request.requestor.email in recipients + assert "procurement_pending_approval" not in verbs + + def test_cancel_notifies_all_related_approvers( + self, api_client, user, department_head, procurement_request, procurement_item, monkeypatch + ): + """Cancel уведомляет всех причастных согласующих.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.APPROVED, + step_name="Руководитель отдела", + ) + Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Финансовый контроль", + ) + sent.clear() + + api_client.force_authenticate(user=procurement_request.requestor) + url = reverse( + 'api:v1:procurement:procurementrequest-cancel', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'reason': 'Передумали'}) + assert response.status_code == status.HTTP_200_OK + + cancelled_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_cancelled" + ] + assert cancelled_recipients == ["head@example.com", "user@example.com"] + + def test_start_work_notifies_requestor_and_approved_approvers( + self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch + ): + """При взятии в работу уведомляются создатель и уже одобрившие этапы.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.APPROVED + procurement_request.executor = None + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.APPROVED, + step_name="Руководитель отдела", + ) + Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.APPROVED, + step_name="Финансовый контроль", + ) + sent.clear() + + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:procurementrequest-start-work', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + in_progress_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_in_progress" + ] + assert in_progress_recipients == [ + procurement_request.requestor.email, + "head@example.com", + "user@example.com", + ] + + def test_complete_notifies_requestor_and_approved_approvers( + self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch + ): + """При завершении уведомляются создатель и уже одобрившие этапы.""" + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + procurement_request.status = ProcurementStatus.IN_PROGRESS + procurement_request.executor = staff_user + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.APPROVED, + step_name="Руководитель отдела", + ) + Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.APPROVED, + step_name="Финансовый контроль", + ) + sent.clear() + + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:procurementrequest-complete', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + completed_recipients = [ + item["recipient"].email + for item in sent + if item["verb"] == "procurement_completed" + ] + assert completed_recipients == [ + procurement_request.requestor.email, + "head@example.com", + "user@example.com", + ] + def test_submit_request( self, api_client, user, procurement_request, procurement_item, budget ): @@ -692,6 +1066,261 @@ class TestProcurementRequestWorkflow: response = api_client.post(url, {'comment': 'Рано'}) assert response.status_code == status.HTTP_403_FORBIDDEN + def test_staff_without_pending_stage_cannot_approve( + self, api_client, staff_user, department_head, procurement_request, procurement_item + ): + """Staff без текущего этапа не должен видеть или выполнять approve.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=staff_user) + + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data['results'][0]['can_current_user_approve'] is False + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data['can_current_user_approve'] is False + + approve_response = api_client.post(approve_url, {'comment': 'Нельзя'}) + assert approve_response.status_code == status.HTTP_403_FORBIDDEN + + def test_superuser_without_pending_stage_cannot_approve( + self, api_client, superuser, department_head, procurement_request, procurement_item + ): + """Суперпользователь не должен получать approve без назначения на этап.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=superuser) + + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data['results'][0]['can_current_user_approve'] is False + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data['can_current_user_approve'] is False + + approve_response = api_client.post(approve_url, {'comment': 'Нельзя'}) + assert approve_response.status_code == status.HTTP_403_FORBIDDEN + + def test_permission_without_pending_stage_cannot_approve( + self, api_client, approver_with_permission, department_head, procurement_request, procurement_item + ): + """Одного model-permission недостаточно без назначения на текущий этап.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=approver_with_permission) + + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data['results'][0]['can_current_user_approve'] is False + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data['can_current_user_approve'] is False + + approve_response = api_client.post(approve_url, {'comment': 'Нельзя'}) + assert approve_response.status_code == status.HTTP_403_FORBIDDEN + + def test_permissioned_current_approver_can_approve( + self, api_client, approver_with_permission, procurement_request, procurement_item + ): + """Если пользователь и назначен, и имеет permission, approve доступен.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=approver_with_permission, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=approver_with_permission) + + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data['results'][0]['can_current_user_approve'] is True + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data['can_current_user_approve'] is True + + approve_response = api_client.post(approve_url, {'comment': 'Можно'}) + assert approve_response.status_code == status.HTTP_200_OK + + def test_requestor_can_approve_when_requestor_is_current_approver( + self, api_client, user, procurement_request, procurement_item + ): + """Если заявитель назначен текущим согласующим, approve для него доступен.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=user, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=user) + + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data['results'][0]['can_current_user_approve'] is True + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data['can_current_user_approve'] is True + + approve_response = api_client.post(approve_url, {'comment': 'Сам себе согласовал'}) + assert approve_response.status_code == status.HTTP_200_OK + + procurement_request.refresh_from_db() + assert procurement_request.status == ProcurementStatus.APPROVED + + def test_list_marks_only_current_approver_as_can_approve( + self, api_client, user, department_head, procurement_request, procurement_item + ): + """Список должен показывать approve/reject только текущему согласующему.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + url = reverse('api:v1:procurement:procurementrequest-list') + + api_client.force_authenticate(user=department_head) + head_response = api_client.get(url) + assert head_response.status_code == status.HTTP_200_OK + assert head_response.data['results'][0]['can_current_user_approve'] is True + + api_client.force_authenticate(user=user) + finance_response = api_client.get(url) + assert finance_response.status_code == status.HTTP_200_OK + assert finance_response.data['results'][0]['can_current_user_approve'] is False + + def test_detail_marks_next_stage_approver_after_previous_stage_complete( + self, api_client, user, department_head, procurement_request, procurement_item + ): + """Detail должен отдавать backend-истину после смены текущего этапа.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.APPROVED, + ) + Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + api_client.force_authenticate(user=department_head) + head_response = api_client.get(url) + assert head_response.status_code == status.HTTP_200_OK + assert head_response.data['can_current_user_approve'] is False + + api_client.force_authenticate(user=user) + finance_response = api_client.get(url) + assert finance_response.status_code == status.HTTP_200_OK + assert finance_response.data['can_current_user_approve'] is True + def test_reject_request( self, api_client, department_head, procurement_request, procurement_item diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 2aeae180..b2c95b7f 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -593,14 +593,22 @@ function EquipmentPageContent() {
- +
+ + +
@@ -702,14 +710,6 @@ function EquipmentPageContent() { )}
-
- -
diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 67395e83..ba1c5f84 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -86,6 +86,7 @@ const periodOptions = [ const fmt = formatDate; const money = formatMoney; +type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete"; const getReadableError = (error: unknown, fallback: string): string => { const raw = String((error as Error)?.message || fallback); @@ -114,6 +115,7 @@ function ProcurementRequestActionButtons({ isAuthor, isExecutor, isFinal, + showApprovalActions = true, showSecondaryActions = true, onSubmit, onEdit, @@ -130,21 +132,23 @@ function ProcurementRequestActionButtons({ isAuthor: boolean; isExecutor: boolean; isFinal: (status?: string) => boolean; + showApprovalActions?: boolean; showSecondaryActions?: boolean; - onSubmit: (id: number) => void | Promise; + onSubmit: (id: number) => void | Promise; onEdit: (request: ProcurementRequest) => void; - onDelete: (id: number) => void | Promise; - onApprove: (id: number) => void | Promise; - onReject: (id: number) => void | Promise; - onStart: (id: number) => void | Promise; - onComplete: (id: number) => void | Promise; - onCancel: (id: number) => void | Promise; + onDelete: (id: number) => void | Promise; + onApprove: (id: number) => void | Promise; + onReject: (id: number) => void | Promise; + onStart: (id: number) => void | Promise; + onComplete: (id: number) => void | Promise; + onCancel: (id: number) => void | Promise; }) { const status = String(request.status || "").toLowerCase(); const isDraft = status === "draft"; const isPending = status === "pending"; const isApproved = status === "approved"; const isInProgress = status === "in_progress"; + const canApproveThis = Boolean(request.can_current_user_approve); return (
@@ -180,7 +184,7 @@ function ProcurementRequestActionButtons({ ) : null} - {isPending && !isAuthor ? ( + {showApprovalActions && isPending && canApproveThis ? ( <> +
+ + +
@@ -651,7 +720,7 @@ export default function ProcurementPage() { disabled={busyKey === `cancel-${req.id}`} onClick={() => { setProcurementMenuOpenId(null); - void handleCancel(req.id); + openRequestActionDialog("cancel", req.id); }} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50" > @@ -678,7 +747,7 @@ export default function ProcurementPage() { disabled={busyKey === `delete-${req.id}`} onClick={() => { setProcurementMenuOpenId(null); - void handleDelete(req.id); + openRequestActionDialog("delete", req.id); }} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50" > @@ -728,14 +797,31 @@ export default function ProcurementPage() { )}
-
- -
+ {canApproveThis ? ( +
+ + + + +
+ ) : null} +
@@ -754,6 +840,7 @@ export default function ProcurementPage() { isAuthor={isAuthor} isExecutor={isExecutor} isFinal={isFinal} + showApprovalActions={false} showSecondaryActions={false} onSubmit={handleSubmit} onEdit={openEdit} @@ -894,12 +981,12 @@ export default function ProcurementPage() { isFinal={isFinal} onSubmit={handleSubmit} onEdit={openEdit} - onDelete={handleDelete} - onApprove={handleApprove} - onReject={handleReject} + onDelete={(id) => openRequestActionDialog("delete", id)} + onApprove={(id) => openRequestActionDialog("approve", id)} + onReject={(id) => openRequestActionDialog("reject", id)} onStart={handleStart} onComplete={handleComplete} - onCancel={handleCancel} + onCancel={(id) => openRequestActionDialog("cancel", id)} /> )} /> @@ -944,6 +1031,95 @@ export default function ProcurementPage() { )} + +
+ {selectedActionRequest ? ( +
+

{selectedActionRequest.title || "Без названия"}

+

+ {getDeptName(selectedActionRequest)}{getRequestAmount(selectedActionRequest) ? ` • ${money(getRequestAmount(selectedActionRequest))}` : ""} +

+
+ ) : null} + + {actionError ? ( +
+ {actionError} +
+ ) : null} + + {requestActionDialog?.kind === "delete" ? ( +

+ Удалить эту заявку? Действие доступно только для черновиков и будет необратимым. +

+ ) : ( +
+ +