diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 83cd8bf0..cbb5d000 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -154,14 +154,10 @@ class CanManageProcurementRequest(permissions.BasePermission): return Department.objects.filter(id=dept_id, head_id=user.id).exists() def _is_dept_member(self, user, dept_id: int) -> bool: - """Проверяет активную принадлежность пользователя к отделу.""" - return ( - EmployeeDepartment.objects.filter( - employee_id=user.id, - department_id=dept_id, - is_active=True, - ).exists() - or self._is_dept_head(user, dept_id) + """Проверяет участие пользователя в отделе, включая role-only.""" + return ProcurementApprovalResolver.user_is_department_participant( + user, + dept_id, ) def has_permission(self, request, view) -> bool: diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 719742de..4173ba32 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -367,6 +367,8 @@ class ProcurementRequestListSerializer( comments_count = serializers.IntegerField(read_only=True, default=0) can_current_user_approve = serializers.SerializerMethodField() can_current_user_submit_for_approval = serializers.SerializerMethodField() + can_current_user_start_work = serializers.SerializerMethodField() + can_current_user_process_items = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): @@ -383,6 +385,18 @@ class ProcurementRequestListSerializer( obj, ) + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_start_work(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_start_work(user, obj) + + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_process_items(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_process_items(user, obj) + class Meta: model = ProcurementRequest fields = [ @@ -416,6 +430,8 @@ class ProcurementRequestListSerializer( "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", + "can_current_user_start_work", + "can_current_user_process_items", "items", "created_at", "submitted_at", @@ -447,6 +463,8 @@ class ProcurementRequestListSerializer( "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", + "can_current_user_start_work", + "can_current_user_process_items", ] def update(self, instance, validated_data): @@ -582,6 +600,8 @@ class ProcurementRequestDetailSerializer( comments_count = serializers.IntegerField(read_only=True, default=0) can_current_user_approve = serializers.SerializerMethodField() can_current_user_submit_for_approval = serializers.SerializerMethodField() + can_current_user_start_work = serializers.SerializerMethodField() + can_current_user_process_items = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_can_current_user_approve(self, obj): @@ -598,6 +618,18 @@ class ProcurementRequestDetailSerializer( obj, ) + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_start_work(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_start_work(user, obj) + + @extend_schema_field(serializers.BooleanField()) + def get_can_current_user_process_items(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return ProcurementApprovalResolver.user_can_process_items(user, obj) + class Meta: model = ProcurementRequest fields = [ @@ -636,6 +668,8 @@ class ProcurementRequestDetailSerializer( "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", + "can_current_user_start_work", + "can_current_user_process_items", "created_at", "updated_at", "submitted_at", @@ -666,6 +700,8 @@ class ProcurementRequestDetailSerializer( "comments_count", "can_current_user_approve", "can_current_user_submit_for_approval", + "can_current_user_start_work", + "can_current_user_process_items", ] diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index f7a0fa83..7edc5759 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -146,6 +146,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): user = self.request.user scope = self.request.query_params.get("scope", None) period = self.request.query_params.get("period", None) + participant_department_ids = ( + ProcurementApprovalResolver.get_user_department_participant_ids( + user, + ) + ) # Обработка scope параметра if scope == "mine": @@ -154,6 +159,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): elif scope == "department": # Заявки моего отдела queryset = queryset.filter(department__in=user.departments.all()) + elif scope == "processing_department": + # Заявки, направленные в отделы, где пользователь участвует. + queryset = queryset.filter( + processing_department_id__in=participant_department_ids, + ) elif scope == "my_work": # Заявки, которые я взял в работу (где я исполнитель) queryset = queryset.filter(executor=user) @@ -174,7 +184,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): queryset = queryset.filter( Q( status=ProcurementStatus.WAITING, - processing_department__in=user.departments.all(), + processing_department_id__in=participant_department_ids, ) | Q( status=ProcurementStatus.APPROVED, @@ -201,7 +211,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): queryset = queryset.filter( Q(requestor=user) | Q(department__in=user.departments.all()) - | Q(processing_department__in=user.departments.all()) + | Q( + processing_department_id__in=participant_department_ids + ) | Q(approvals__approver=user) | Q( status=ProcurementStatus.APPROVED, @@ -453,8 +465,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Проверяем права self.check_object_permissions(request, procurement_request) - # Находим согласование текущего пользователя - approval = ProcurementApprovalResolver.get_available_approval( + # Находим или создаем доступный этап текущего пользователя. + approval = ProcurementApprovalResolver.get_or_create_available_approval( request.user, procurement_request, ) @@ -510,8 +522,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Проверяем права self.check_object_permissions(request, procurement_request) - # Находим согласование текущего пользователя - approval = ProcurementApprovalResolver.get_available_approval( + # Находим или создаем доступный этап текущего пользователя. + approval = ProcurementApprovalResolver.get_or_create_available_approval( request.user, procurement_request, ) @@ -522,6 +534,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_403_FORBIDDEN, ) + # Вышестоящий согласующий закрывает предыдущие pending-этапы. + procurement_request.approvals.filter( + status=ApprovalStatus.PENDING, + priority__lt=approval.priority, + ).update( + status=ApprovalStatus.REJECTED, + comment="Автоматически закрыто вышестоящим отклонением", + updated_at=timezone.now(), + ) + # Отклоняем approval.status = ApprovalStatus.REJECTED approval.comment = request.data.get("comment", "") @@ -554,13 +576,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): def pending_approvals(self, request): """Получить заявки, ожидающие согласования текущим польз.""" # Находим заявки где есть pending approval для текущего юзера - base_queryset = ( - self.filter_queryset(self.get_queryset()) - .filter( - approvals__approver=request.user, - approvals__status=ApprovalStatus.PENDING, + base_queryset = self.filter_queryset( + self.queryset.filter( + status=ProcurementStatus.PENDING, ) - .distinct() ) available_ids = [ request_obj.id @@ -611,10 +630,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): or request.user.has_perm("procurement.change_procurementrequest") or request.user.has_perm("procurement.execute_procurement") ): + participant_department_ids = ( + ProcurementApprovalResolver.get_user_department_participant_ids( + request.user, + ) + ) queryset = queryset.filter( Q( status=ProcurementStatus.WAITING, - processing_department__in=request.user.departments.all(), + processing_department_id__in=participant_department_ids, ) | Q( status=ProcurementStatus.APPROVED, @@ -728,18 +752,21 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - items_to_update = list( - procurement_request.items.exclude( - execution_status=ProcurementItemExecutionStatus.RECEIVED, + items_to_update = [ + item + for item in procurement_request.items.all() + if ( + item.execution_status + != ProcurementItemExecutionStatus.RECEIVED + or item.ordered_quantity != item.quantity + or item.received_quantity != item.quantity ) - ) + ] for item in items_to_update: item.execution_status = ProcurementItemExecutionStatus.RECEIVED - if item.ordered_quantity is None: - item.ordered_quantity = item.quantity - if item.received_quantity is None: - item.received_quantity = item.quantity + item.ordered_quantity = item.quantity + item.received_quantity = item.quantity ProcurementItem.objects.bulk_update( items_to_update, ["execution_status", "ordered_quantity", "received_quantity"], @@ -856,15 +883,9 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): ) def _is_processing_department_member(self, user, procurement_request): - if not procurement_request.processing_department_id: - return False - return ( - EmployeeDepartment.objects.filter( - employee_id=user.id, - department_id=procurement_request.processing_department_id, - is_active=True, - ).exists() - or procurement_request.processing_department.head_id == user.id + return ProcurementApprovalResolver.user_is_processing_department_member( + user, + procurement_request, ) def _can_process_request_items(self, user, procurement_request): diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 9466666a..213032a2 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -376,11 +376,13 @@ def notify_request_completed(request): request: Объект ProcurementRequest """ notification_title, description = MessageTemplates.completed(request.title) + actor = _request_actor(request) or request.executor notify_requestor( request, NotificationVerbs.COMPLETED, notification_title, description, + actor=actor, extra_data={ "old_status": getattr(request, "_original_status", None), "new_status": request.status, diff --git a/backend/procurement/services.py b/backend/procurement/services.py index d9f50592..37863689 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -95,19 +95,75 @@ class ProcurementApprovalResolver: """ @classmethod - def user_is_processing_department_member(cls, user, procurement_request): - if not procurement_request.processing_department_id: + def user_is_department_participant(cls, user, department_id): + """Проверяет рабочее участие пользователя в отделе. + + Участником считается физический сотрудник отдела, руководитель отдела + или сотрудник с активной ролью в этом отделе. + """ + if not department_id or not user or not user.is_authenticated: return False - from employees.models import EmployeeDepartment + from employees.models import ( + Department, + EmployeeDepartment, + RoleAssignment, + ) return ( EmployeeDepartment.objects.filter( employee_id=user.id, - department_id=procurement_request.processing_department_id, + department_id=department_id, is_active=True, ).exists() - or procurement_request.processing_department.head_id == user.id + or Department.objects.filter( + id=department_id, + head_id=user.id, + ).exists() + or RoleAssignment.objects.filter( + employee_id=user.id, + role__department_id=department_id, + is_active=True, + ).exists() + ) + + @classmethod + def get_user_department_participant_ids(cls, user): + """Возвращает отделы, где пользователь участник или role-only участник.""" + if not user or not user.is_authenticated: + return [] + + from employees.models import ( + Department, + EmployeeDepartment, + RoleAssignment, + ) + + department_ids = set( + EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True, + ).values_list("department_id", flat=True) + ) + department_ids.update( + Department.objects.filter(head_id=user.id).values_list( + "id", + flat=True, + ) + ) + department_ids.update( + RoleAssignment.objects.filter( + employee_id=user.id, + is_active=True, + ).values_list("role__department_id", flat=True) + ) + return sorted(department_ids) + + @classmethod + def user_is_processing_department_member(cls, user, procurement_request): + return cls.user_is_department_participant( + user, + procurement_request.processing_department_id, ) @classmethod @@ -120,6 +176,9 @@ class ProcurementApprovalResolver: or procurement_request.approvals.filter( status=ApprovalStatus.PENDING, ).exists() + or procurement_request.approvals.filter( + status=ApprovalStatus.APPROVED, + ).exists() ): return False @@ -148,6 +207,64 @@ class ProcurementApprovalResolver: and procurement_request.is_editable ) + @classmethod + def user_can_start_work(cls, user, procurement_request) -> bool: + if not user or not user.is_authenticated: + return False + + if ( + procurement_request.status + not in {ProcurementStatus.APPROVED, ProcurementStatus.WAITING} + or procurement_request.executor_id + ): + return False + + if ( + user.is_superuser + or user.is_staff + or user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + return True + + if procurement_request.processing_department_id: + return cls.user_is_processing_department_member( + user, + procurement_request, + ) + + return True + + @classmethod + def user_can_process_items(cls, user, procurement_request) -> bool: + if not user or not user.is_authenticated: + return False + + if procurement_request.status not in { + ProcurementStatus.WAITING, + ProcurementStatus.IN_PROGRESS, + }: + return False + + if procurement_request.executor_id == user.id: + return True + + if ( + user.is_superuser + or user.is_staff + or user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + return True + + if procurement_request.processing_department_id: + return cls.user_is_processing_department_member( + user, + procurement_request, + ) + + return False + @classmethod def resolve_approval_step(cls, procurement_request, route): from .models import ApprovalRoute @@ -241,6 +358,9 @@ class ProcurementApprovalResolver: if not user or not user.is_authenticated: return None + if procurement_request.status != ProcurementStatus.PENDING: + return None + pending = procurement_request.approvals.filter( status=ApprovalStatus.PENDING, ).order_by("priority", "created_at", "id") @@ -253,11 +373,80 @@ class ProcurementApprovalResolver: priority__gte=first_pending.priority, ).first() + @classmethod + def get_available_higher_route(cls, user, procurement_request): + if not user or not user.is_authenticated: + return None + + if procurement_request.status != ProcurementStatus.PENDING: + return None + + first_pending = ( + procurement_request.approvals.filter( + status=ApprovalStatus.PENDING, + ) + .order_by("priority", "created_at", "id") + .first() + ) + if first_pending is None: + return None + + from .models import ApprovalRoute + + existing_priorities = set( + procurement_request.approvals.values_list("priority", flat=True) + ) + routes = ( + ApprovalRoute.objects.select_related("employee") + .filter(priority__gt=first_pending.priority) + .order_by("priority", "id") + ) + for route in routes: + if route.priority in existing_priorities: + continue + step = cls.resolve_approval_step(procurement_request, route) + if step and step[1].id == user.id: + return step + return None + + @classmethod + def get_or_create_available_approval(cls, user, procurement_request): + approval = cls.get_available_approval(user, procurement_request) + if approval is not None: + return approval + + step = cls.get_available_higher_route(user, procurement_request) + if step is None: + return None + + from .models import Approval + + priority, approver, step_name = step + approval, _ = Approval.objects.get_or_create( + request=procurement_request, + priority=priority, + defaults={ + "approver": approver, + "step_name": step_name, + "status": ApprovalStatus.PENDING, + }, + ) + if ( + approval.approver_id != user.id + or approval.status != ApprovalStatus.PENDING + ): + return None + return approval + @classmethod def user_can_approve(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: return False - return cls.get_available_approval(user, procurement_request) is not None + return ( + cls.get_available_approval(user, procurement_request) is not None + or cls.get_available_higher_route(user, procurement_request) + is not None + ) class EquipmentService: diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 1bbd6105..c437aa8f 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -739,6 +739,29 @@ class TestProcessingDepartmentWorkflow: ) return employee + @pytest.fixture + def supply_role_user(self, db, supply_department): + employee = Employee.objects.create_user( + email="supply-role@example.com", + password="testpass123", + phone_number="+79998888892", + first_name="Ролевой", + last_name="Исполнитель", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + role = DepartmentRole.objects.create( + department=supply_department, + name="Закупщик", + ) + RoleAssignment.objects.create( + employee=employee, + role=role, + is_active=True, + ) + return employee + @pytest.fixture def outsider(self, db): return Employee.objects.create_user( @@ -935,6 +958,82 @@ class TestProcessingDepartmentWorkflow: ids = [item['id'] for item in response.data['results']] assert processing_request.id in ids + def test_role_assignment_user_sees_processing_department_request( + self, api_client, supply_role_user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=supply_role_user) + + available_url = reverse( + 'api:v1:procurement:procurementrequest-available' + ) + available_response = api_client.get(available_url) + assert available_response.status_code == status.HTTP_200_OK + available_ids = [ + item['id'] for item in available_response.data['results'] + ] + assert processing_request.id in available_ids + + list_url = reverse('api:v1:procurement:procurementrequest-list') + list_response = api_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + list_ids = [item['id'] for item in list_response.data['results']] + assert processing_request.id in list_ids + + def test_processing_department_scope_uses_executor_department_participants( + self, api_client, supply_role_user, user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + list_url = reverse('api:v1:procurement:procurementrequest-list') + + api_client.force_authenticate(user=supply_role_user) + processing_response = api_client.get( + f"{list_url}?scope=processing_department" + ) + assert processing_response.status_code == status.HTTP_200_OK + processing_ids = [ + item['id'] for item in processing_response.data['results'] + ] + assert processing_request.id in processing_ids + + api_client.force_authenticate(user=user) + department_response = api_client.get(f"{list_url}?scope=department") + assert department_response.status_code == status.HTTP_200_OK + department_ids = [ + item['id'] for item in department_response.data['results'] + ] + assert processing_request.id in department_ids + + def test_processing_permission_flags_follow_executor_department( + self, api_client, user, supply_user, supply_role_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': processing_request.id}, + ) + + api_client.force_authenticate(user=user) + author_response = api_client.get(detail_url) + assert author_response.status_code == status.HTTP_200_OK + assert author_response.data["can_current_user_start_work"] is False + assert author_response.data["can_current_user_process_items"] is False + + api_client.force_authenticate(user=supply_user) + supply_response = api_client.get(detail_url) + assert supply_response.status_code == status.HTTP_200_OK + assert supply_response.data["can_current_user_start_work"] is True + assert supply_response.data["can_current_user_process_items"] is True + + api_client.force_authenticate(user=supply_role_user) + role_response = api_client.get(detail_url) + assert role_response.status_code == status.HTTP_200_OK + assert role_response.data["can_current_user_start_work"] is True + assert role_response.data["can_current_user_process_items"] is True + def test_outsider_cannot_start_processing_department_request( self, api_client, outsider, processing_request, procurement_item_factory ): @@ -982,6 +1081,63 @@ class TestProcessingDepartmentWorkflow: ) assert edit_response.status_code == status.HTTP_403_FORBIDDEN + def test_processing_department_role_user_can_start_work( + self, api_client, supply_role_user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=supply_role_user) + url = reverse( + 'api:v1:procurement:procurementrequest-start-work', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.executor == supply_role_user + assert processing_request.status == ProcurementStatus.IN_PROGRESS + + def test_processing_department_role_user_can_manage_items( + self, api_client, supply_role_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=2, + ) + api_client.force_authenticate(user=supply_role_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + update_response = api_client.patch( + item_url, + { + 'execution_status': ProcurementItemExecutionStatus.ORDERED, + 'ordered_quantity': 1, + }, + format='json', + ) + + assert update_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + assert item.ordered_quantity == 1 + + mark_all_url = reverse( + 'api:v1:procurement:procurementrequest-mark-all-received', + kwargs={'pk': processing_request.id}, + ) + mark_all_response = api_client.post(mark_all_url) + + assert mark_all_response.status_code == status.HTTP_200_OK + item.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED + assert item.received_quantity == item.quantity + def test_author_can_edit_waiting_request_with_items_before_work( self, api_client, user, department, supply_department, processing_request, procurement_item_factory @@ -1085,6 +1241,24 @@ class TestProcessingDepartmentWorkflow: assert processing_request.approvals.count() == 1 assert response.data["can_current_user_submit_for_approval"] is False + def test_processing_department_role_user_can_submit_for_approval( + self, api_client, supply_role_user, processing_request, + procurement_item_factory, department_head_approval_route + ): + procurement_item_factory(request=processing_request) + api_client.force_authenticate(user=supply_role_user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.PENDING + assert processing_request.approvals.count() == 1 + def test_processing_department_requestor_cannot_submit_for_approval( self, api_client, user, processing_request, procurement_item_factory, department_head_approval_route @@ -1127,6 +1301,39 @@ class TestProcessingDepartmentWorkflow: assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_approved_processing_request_cannot_be_submitted_again( + self, api_client, supply_user, processing_request, + procurement_item_factory, department_head_approval_route + ): + """После пройденного согласования кнопку submit больше не показываем.""" + procurement_item_factory(request=processing_request) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save(update_fields=["executor", "status", "updated_at"]) + Approval.objects.create( + request=processing_request, + approver=processing_request.department.head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.APPROVED, + ) + + api_client.force_authenticate(user=supply_user) + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': processing_request.id}, + ) + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': processing_request.id}, + ) + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data["can_current_user_submit_for_approval"] is False + + submit_response = api_client.post(submit_url) + assert submit_response.status_code == status.HTTP_403_FORBIDDEN + def test_final_approve_returns_processing_request_to_waiting_without_executor( self, api_client, supply_user, department_head, processing_request, procurement_item_factory, department_head_approval_route @@ -1306,6 +1513,47 @@ class TestProcessingDepartmentWorkflow: assert item.ordered_quantity == item.quantity assert item.received_quantity == item.quantity + def test_item_auto_completion_notification_uses_executor_actor( + self, api_client, supply_user, processing_request, + procurement_item_factory, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + item = procurement_item_factory(request=processing_request) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + sent.clear() + + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + {'received_quantity': item.quantity}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + completed_notifications = [ + item for item in sent + if item["verb"] == "procurement_completed" + ] + assert len(completed_notifications) == 1 + assert completed_notifications[0]["sender"] == supply_user + assert completed_notifications[0]["data"]["actor_id"] == supply_user.id + def test_item_update_notifies_requestor( self, api_client, supply_user, processing_request, procurement_item_factory, monkeypatch @@ -1691,6 +1939,39 @@ class TestProcessingDepartmentWorkflow: assert result["total_ordered_quantity"] == 6 assert result["total_received_quantity"] == 3 + def test_list_expected_date_falls_back_to_latest_when_all_received( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory( + request=processing_request, + quantity=1, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ordered_quantity=1, + received_quantity=1, + expected_delivery_date="2026-05-28", + ) + procurement_item_factory( + request=processing_request, + name="Вторая полученная позиция", + quantity=1, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ordered_quantity=1, + received_quantity=1, + expected_delivery_date="2026-05-30", + ) + api_client.force_authenticate(user=supply_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + result = next( + item for item in response.data["results"] + if item["id"] == processing_request.id + ) + assert result["next_expected_delivery_date"] == "2026-05-30" + def test_requestor_item_update_does_not_notify_self( self, api_client, user, processing_request, procurement_item_factory, monkeypatch @@ -1847,7 +2128,12 @@ class TestProcessingDepartmentWorkflow: fake_notify_send, ) - item_1 = procurement_item_factory(request=processing_request) + item_1 = procurement_item_factory( + request=processing_request, + quantity=2, + execution_status=ProcurementItemExecutionStatus.ORDERED, + ordered_quantity=1, + ) item_2 = procurement_item_factory( request=processing_request, name="Лампы", @@ -2580,6 +2866,83 @@ class TestProcurementRequestWorkflow: assert director_approval.comment == "Финальное согласование" assert procurement_request.status == ProcurementStatus.APPROVED + def test_higher_approver_reject_closes_previous_stages( + self, api_client, user, department_head, procurement_request, + procurement_item, budget + ): + """Вышестоящее отклонение закрывает предыдущие pending-этапы.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + head_approval = Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + ) + finance_approval = Approval.objects.create( + request=procurement_request, + approver=user, + priority=FINANCE_PRIORITY, + status=ApprovalStatus.PENDING, + ) + + api_client.force_authenticate(user=user) + 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 + + head_approval.refresh_from_db() + finance_approval.refresh_from_db() + procurement_request.refresh_from_db() + + assert head_approval.status == ApprovalStatus.REJECTED + assert "вышестоящим" in head_approval.comment + assert finance_approval.status == ApprovalStatus.REJECTED + assert finance_approval.comment == "Отклонено сверху" + assert procurement_request.status == ProcurementStatus.REJECTED + + def test_pending_approval_does_not_allow_approve_when_request_not_pending( + self, api_client, department_head, procurement_request, procurement_item + ): + """Pending-запись не дает approve, если сама заявка уже не pending.""" + procurement_request.status = ProcurementStatus.APPROVED + 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=department_head) + + 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_staff_without_pending_stage_cannot_approve( self, api_client, staff_user, department_head, procurement_request, procurement_item ): @@ -2800,6 +3163,93 @@ class TestProcurementRequestWorkflow: assert finance_response.status_code == status.HTTP_200_OK assert finance_response.data['results'][0]['can_current_user_approve'] is True + def test_pending_approvals_includes_higher_approver( + self, api_client, user, department_head, procurement_request, procurement_item + ): + """Вкладка согласования должна показывать заявку вышестоящему этапу.""" + 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-pending-approvals') + + api_client.force_authenticate(user=user) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert [item['id'] for item in response.data['results']] == [procurement_request.id] + assert response.data['results'][0]['can_current_user_approve'] is True + + def test_higher_route_can_approve_even_if_not_required_by_amount( + self, api_client, user, department + ): + """Вышестоящий маршрут может согласовать заявку ниже своего порога.""" + procurement_request = ProcurementRequest.objects.create( + title="Низкая сумма для обхода сверху", + description="Проверка согласования верхним маршрутом", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + urgency=UrgencyLevel.MEDIUM, + ) + ProcurementItem.objects.create( + request=procurement_request, + name="Дешевая позиция", + quantity=1, + unit="шт", + estimated_unit_price=Decimal("12.00"), + ) + + api_client.force_authenticate(user=user) + submit_url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + submit_response = api_client.post(submit_url) + assert submit_response.status_code == status.HTTP_200_OK + + procurement_request.refresh_from_db() + assert list( + procurement_request.approvals.order_by('priority').values_list('priority', flat=True) + ) == [HEAD_PRIORITY] + + pending_url = reverse('api:v1:procurement:procurementrequest-pending-approvals') + pending_response = api_client.get(pending_url) + assert pending_response.status_code == status.HTTP_200_OK + assert [item['id'] for item in pending_response.data['results']] == [procurement_request.id] + assert pending_response.data['results'][0]['can_current_user_approve'] is True + + approve_url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + approve_response = api_client.post(approve_url, {'comment': 'Согласовано выше порога'}) + assert approve_response.status_code == status.HTTP_200_OK + + procurement_request.refresh_from_db() + approvals = procurement_request.approvals.order_by('priority') + assert list(approvals.values_list('priority', flat=True)) == [ + HEAD_PRIORITY, + FINANCE_PRIORITY, + ] + assert list(approvals.values_list('status', flat=True)) == [ + ApprovalStatus.APPROVED, + ApprovalStatus.APPROVED, + ] + assert procurement_request.status == ProcurementStatus.APPROVED + def test_detail_marks_next_stage_approver_after_previous_stage_complete( self, api_client, user, department_head, procurement_request, procurement_item ): diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 291bd6ee..cd64298d 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -74,11 +74,12 @@ const orderingOptions = [ { value: "-urgency", label: "По срочности ↓" }, ]; -type ScopeTab = "all" | "mine" | "department" | "pending_approvals" | "my_work" | "available"; +type ScopeTab = "all" | "mine" | "department" | "processing_department" | "pending_approvals" | "my_work" | "available"; const scopeTabs: { value: ScopeTab; label: string }[] = [ { value: "all", label: "Все" }, { value: "mine", label: "Мои" }, - { value: "department", label: "Мой отдел" }, + { value: "department", label: "Отдел-заказчик" }, + { value: "processing_department", label: "Отдел-исполнитель" }, { value: "pending_approvals", label: "На согласование" }, { value: "my_work", label: "В работе у меня" }, { value: "available", label: "Доступные" }, @@ -156,7 +157,7 @@ function ProcurementRequestActionButtons({ const status = String(request.status || "").toLowerCase(); const isDraft = status === "draft"; const isPending = status === "pending"; - const canStartWork = (status === "approved" || status === "waiting") && !request.executor; + const canStartWork = Boolean(request.can_current_user_start_work); const isInProgress = status === "in_progress"; const canApproveThis = Boolean(request.can_current_user_approve); const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval); @@ -1051,7 +1052,7 @@ export default function ProcurementPage() { currentUserId={user?.id} request={resolvedDetail} displayUserName={displayUserName} - canProcessItems={["waiting", "in_progress"].includes(st)} + canProcessItems={Boolean(resolvedDetail.can_current_user_process_items)} busyKey={busyKey} canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} onUpdateItem={handleUpdateItem} @@ -1066,13 +1067,12 @@ export default function ProcurementPage() { onDeleteItemComment={handleDeleteItemComment} footer={( = { @@ -149,6 +149,7 @@ export function useProcurementPage(user: User | null) { all: 0, mine: 0, department: 0, + processing_department: 0, pending_approvals: 0, my_work: 0, available: 0, @@ -225,6 +226,7 @@ export function useProcurementPage(user: User | null) { const params: Record = { page }; if (scope === "mine") params.scope = "mine"; else if (scope === "department") params.scope = "department"; + else if (scope === "processing_department") params.scope = "processing_department"; else if (scope === "my_work") params.scope = "my_work"; else if (scope === "available") params.scope = "available"; if (statusFilter.length === 1) params.status = statusFilter[0]; @@ -243,6 +245,7 @@ export function useProcurementPage(user: User | null) { const params: Record = { page: 1 }; if (targetScope === "mine") params.scope = "mine"; else if (targetScope === "department") params.scope = "department"; + else if (targetScope === "processing_department") params.scope = "processing_department"; else if (targetScope === "my_work") params.scope = "my_work"; else if (targetScope === "available") params.scope = "available"; if (statusFilter.length === 1) params.status = statusFilter[0]; @@ -282,33 +285,44 @@ export function useProcurementPage(user: User | null) { void loadPage1(); }, [loadPage1]); + const loadScopeCounts = useCallback(async () => { + try { + const scopes: ScopeTab[] = [ + "all", + "mine", + "department", + "processing_department", + "pending_approvals", + "my_work", + "available", + ]; + const results = await Promise.all( + scopes.map(async (scopeKey) => { + const response: unknown = scopeKey === "pending_approvals" + ? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey)) + : await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey)); + return [scopeKey, getPaginatedCount(response)] as const; + }), + ); + + setScopeCounts(Object.fromEntries(results) as Record); + } catch (countsError) { + console.error("Load procurement scope counts error:", countsError); + } + }, [buildScopeCountParams]); + useEffect(() => { let cancelled = false; void (async () => { - try { - const scopes: ScopeTab[] = ["all", "mine", "department", "pending_approvals", "my_work", "available"]; - const results = await Promise.all( - scopes.map(async (scopeKey) => { - const response: unknown = scopeKey === "pending_approvals" - ? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey)) - : await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey)); - return [scopeKey, getPaginatedCount(response)] as const; - }), - ); - - if (!cancelled) { - setScopeCounts(Object.fromEntries(results) as Record); - } - } catch (countsError) { - console.error("Load procurement scope counts error:", countsError); - } + if (cancelled) return; + await loadScopeCounts(); })(); return () => { cancelled = true; }; - }, [buildScopeCountParams]); + }, [loadScopeCounts]); useEffect(() => { let cancelled = false; @@ -465,13 +479,33 @@ export function useProcurementPage(user: User | null) { const refreshOne = useCallback(async (id: number) => { try { const updated = await apiClient.getProcurementRequest(id); + detailsCacheRef.current = { + ...detailsCacheRef.current, + [id]: updated, + }; setRequests((previous) => previous.map((request) => (request.id === id ? updated : request))); - setDetailsCache((previous) => ({ ...previous, [id]: updated })); + setDetailsCache((previous) => { + const next = { ...previous, [id]: updated }; + detailsCacheRef.current = next; + return next; + }); } catch { await loadPage1(); } }, [loadPage1]); + const refreshCurrentView = useCallback(async () => { + await Promise.all([ + loadPage1(), + loadScopeCounts(), + ]); + }, [loadPage1, loadScopeCounts]); + + const refreshAfterMutation = useCallback(async (id: number) => { + await refreshOne(id); + await refreshCurrentView(); + }, [refreshCurrentView, refreshOne]); + const ensureRequestDetail = useCallback(async (id: number) => { const cached = detailsCacheRef.current[id]; if (cached) { @@ -559,13 +593,13 @@ export function useProcurementPage(user: User | null) { } resetForm(); - await loadPage1(); + await refreshCurrentView(); } catch (saveError) { setActionError(getReadableError(saveError, "Ошибка сохранения")); } finally { setBusyKey(null); } - }, [editingId, form, loadPage1, modalMode, resetForm]); + }, [editingId, form, modalMode, refreshCurrentView, resetForm]); const handleSave = useCallback(() => saveRequest(), [saveRequest]); @@ -575,7 +609,7 @@ export function useProcurementPage(user: User | null) { setActionError(null); await action(); setActionSuccess(successMessage); - await refreshOne(id); + await refreshAfterMutation(id); return true; } catch (actionErrorValue) { setActionError(getReadableError(actionErrorValue, "Ошибка")); @@ -583,7 +617,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshOne]); + }, [refreshAfterMutation]); const handleLoadMore = useCallback(async () => { if (!nextPage || loadingMore) return; @@ -858,7 +892,7 @@ export function useProcurementPage(user: User | null) { } await apiClient.updateProcurementItem(itemId, patch); setActionSuccess("Позиция обновлена."); - await refreshOne(requestId); + await refreshAfterMutation(requestId); return true; } catch (updateError) { setActionError(getReadableError(updateError, "Не удалось обновить позицию")); @@ -866,7 +900,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshOne]); + }, [refreshAfterMutation]); const handleReportItemIssue = useCallback(async (requestId: number, itemId: number, text = "") => { try { @@ -874,7 +908,7 @@ export function useProcurementPage(user: User | null) { setActionError(null); await apiClient.reportProcurementItemIssue(itemId, text); setActionSuccess("Позиция отмечена как проблемная."); - await refreshOne(requestId); + await refreshAfterMutation(requestId); return true; } catch (issueError) { setActionError(getReadableError(issueError, "Не удалось отметить проблему")); @@ -882,7 +916,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshOne]); + }, [refreshAfterMutation]); const handleCancel = useCallback((id: number, reason = "") => { return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена."); @@ -901,6 +935,7 @@ export function useProcurementPage(user: User | null) { return next; }); setActionSuccess("Заявка удалена."); + await refreshCurrentView(); return true; } catch (deleteError) { setActionError(getReadableError(deleteError, "Не удалось удалить")); @@ -908,7 +943,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, []); + }, [refreshCurrentView]); const addItemRow = useCallback(() => { setForm((previous) => ({ ...previous, items: [{ ...emptyItem }, ...previous.items] })); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2e356c38..0f60eb08 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -562,6 +562,8 @@ export interface ProcurementRequest { comments_count?: number; can_current_user_approve?: boolean; can_current_user_submit_for_approval?: boolean; + can_current_user_start_work?: boolean; + can_current_user_process_items?: boolean; created_at: string; updated_at: string; submitted_at?: string | null;