diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 6dc0291c..96836c55 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -129,6 +129,12 @@ class CanManageProcurementRequest(permissions.BasePermission): perm = perm_map.get(action) return perm and user.has_perm(perm) + def _has_procurement_execute_perm(self, user) -> bool: + return ( + user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ) + def _is_dept_head(self, user, dept_id: int) -> bool: """Проверяет, является ли пользователь начальником отдела.""" return Department.objects.filter(id=dept_id, head_id=user.id).exists() @@ -211,6 +217,8 @@ class CanManageProcurementRequest(permissions.BasePermission): # Start_work: общий пул может взять любой, адресную заявку - отдел. if action == "start_work": + if self._has_procurement_execute_perm(user): + return True if obj.processing_department_id: return self._is_dept_member( user, diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 0b964019..d71b458c 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -28,6 +28,7 @@ from procurement.models import ( Supplier, ) from procurement.notifications.handlers import ( + notify_executor_reassigned, notify_item_comment, notify_item_issue_reported, notify_item_updated, @@ -192,11 +193,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): queryset = queryset.filter(executor=user) elif scope == "available": queryset = queryset.filter( - status__in=[ - ProcurementStatus.WAITING, - ProcurementStatus.APPROVED, - ], - executor__isnull=True, + Q( + status__in=[ + ProcurementStatus.WAITING, + ProcurementStatus.APPROVED, + ], + executor__isnull=True, + ) + | Q( + status=ProcurementStatus.IN_PROGRESS, + processing_department__isnull=False, + executor__isnull=False, + ) ) if not ( user.is_superuser @@ -213,6 +221,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=ProcurementStatus.APPROVED, processing_department__isnull=True, ) + | ( + Q( + status=ProcurementStatus.IN_PROGRESS, + processing_department_id__in=participant_department_ids, + ) + & ~Q(executor=user) + ) + ) + else: + queryset = queryset.exclude( + status=ProcurementStatus.IN_PROGRESS, + executor=user, ) elif scope == "all": # Все заявки - применяем стандартную логику прав @@ -227,6 +247,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): user.has_perm("procurement.view_procurementrequest") or user.has_perm("procurement.change_procurementrequest") or user.has_perm("procurement.delete_procurementrequest") + or user.has_perm("procurement.execute_procurement") ): pass else: @@ -639,11 +660,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Получить заявки, доступные для взятия в работу.""" queryset = self.filter_queryset( self.get_queryset().filter( - status__in=[ - ProcurementStatus.WAITING, - ProcurementStatus.APPROVED, - ], - executor__isnull=True, + Q( + status__in=[ + ProcurementStatus.WAITING, + ProcurementStatus.APPROVED, + ], + executor__isnull=True, + ) + | Q( + status=ProcurementStatus.IN_PROGRESS, + processing_department__isnull=False, + executor__isnull=False, + ) ) ) @@ -667,6 +695,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=ProcurementStatus.APPROVED, processing_department__isnull=True, ) + | ( + Q( + status=ProcurementStatus.IN_PROGRESS, + processing_department_id__in=participant_department_ids, + ) + & ~Q(executor=request.user) + ) + ) + else: + queryset = queryset.exclude( + status=ProcurementStatus.IN_PROGRESS, + executor=request.user, ) page = self.paginate_queryset(queryset) @@ -683,15 +723,52 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): Общий пул может взять любой авторизованный пользователь. Адресную заявку может взять сотрудник отдела-исполнителя. + Адресную заявку в работе может забрать другой сотрудник отдела. """ procurement_request = self.get_object() self.check_object_permissions(request, procurement_request) - # Проверяем текущий статус - if procurement_request.status not in [ + is_reassignment = ( + procurement_request.status == ProcurementStatus.IN_PROGRESS + and procurement_request.processing_department_id + and procurement_request.executor_id + ) + + if is_reassignment: + previous_executor = procurement_request.executor + if previous_executor == request.user: + return Response( + {"error": "Вы уже являетесь исполнителем заявки"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not ProcurementApprovalResolver.user_can_start_work( + request.user, + procurement_request, + ): + return Response( + {"error": "Вы не можете забрать эту заявку в работу"}, + status=status.HTTP_403_FORBIDDEN, + ) + + procurement_request.executor = request.user + procurement_request.started_at = timezone.now() + procurement_request.save( + update_fields=["executor", "started_at", "updated_at"], + ) + notify_executor_reassigned( + procurement_request, + previous_executor, + request.user, + ) + + serializer = self.get_serializer(procurement_request) + return Response(serializer.data) + + if procurement_request.status not in { ProcurementStatus.APPROVED, ProcurementStatus.WAITING, - ]: + }: return Response( { "error": ( @@ -714,6 +791,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) + if not ProcurementApprovalResolver.user_can_start_work( + request.user, + procurement_request, + ): + return Response( + {"error": "Вы не можете взять эту заявку в работу"}, + status=status.HTTP_403_FORBIDDEN, + ) + # Назначаем исполнителя и меняем статус # Сигнал post_save(ProcurementRequest) с IN_PROGRESS отправит # уведомления diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index 7ad83d91..d63a5d02 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -36,6 +36,7 @@ from .handlers import ( notify_request_rejected, notify_request_completed, notify_request_in_progress, + notify_executor_reassigned, notify_request_returned_to_processing, notify_request_cancelled, notify_stage_approved, @@ -66,6 +67,7 @@ __all__ = [ 'notify_request_rejected', 'notify_request_completed', 'notify_request_in_progress', + 'notify_executor_reassigned', 'notify_request_returned_to_processing', 'notify_request_cancelled', 'notify_stage_approved', diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py index 88445a30..92eb3503 100644 --- a/backend/procurement/notifications/config.py +++ b/backend/procurement/notifications/config.py @@ -22,6 +22,7 @@ class NotificationVerbs: APPROVED = 'procurement_approved' REJECTED = 'procurement_rejected' IN_PROGRESS = 'procurement_in_progress' + EXECUTOR_REASSIGNED = 'procurement_executor_reassigned' COMPLETED = 'procurement_completed' CANCELLED = 'procurement_cancelled' @@ -141,6 +142,16 @@ class MessageTemplates: ) return notification_title, description + @staticmethod + def executor_reassigned( + title: str, executor_name: str + ) -> tuple[str, str]: + notification_title = 'Заявку забрал другой сотрудник' + description = ( + f'{executor_name} взял в работу заявку "{title}".' + ) + return notification_title, description + @staticmethod def in_progress( title: str, executor_name: str diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 213032a2..b99edc78 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -420,6 +420,36 @@ def notify_request_in_progress(request, executor): ) +def notify_executor_reassigned(request, previous_executor, actor): + """Уведомить прежнего исполнителя о перехвате адресной заявки.""" + if not previous_executor: + return + if actor and previous_executor.id == actor.id: + return + + notification_title, description = MessageTemplates.executor_reassigned( + request.title, + _actor_name(actor), + ) + _send_procurement_notification( + recipient=previous_executor, + verb=NotificationVerbs.EXECUTOR_REASSIGNED, + procurement_request=request, + title=notification_title, + description=description, + actor=actor, + extra_data={ + "old_executor_id": previous_executor.id, + "new_executor_id": actor.id if actor else None, + }, + ) + + logger.info( + f"[Procurement] Исполнитель заявки #{request.id} изменён: " + f"{previous_executor.id} -> {_actor_id(actor)}" + ) + + def notify_request_cancelled(request): """ Уведомить об отмене заявки. diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 3a2d69c4..338f9f24 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -212,19 +212,37 @@ class ProcurementApprovalResolver: 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 ( + has_execute_access = ( user.is_superuser or user.is_staff or user.has_perm("procurement.change_procurementrequest") or user.has_perm("procurement.execute_procurement") + ) + + if ( + procurement_request.status == ProcurementStatus.IN_PROGRESS + and procurement_request.processing_department_id + and procurement_request.executor_id ): + if procurement_request.executor_id == user.id: + return False + if has_execute_access: + return True + return cls.user_is_processing_department_member( + user, + procurement_request, + ) + + if procurement_request.status not in { + ProcurementStatus.APPROVED, + ProcurementStatus.WAITING, + }: + return False + + if procurement_request.executor_id: + return False + + if has_execute_access: return True if procurement_request.processing_department_id: diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 93ff9e10..3362886a 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -768,6 +768,42 @@ class TestProcessingDepartmentWorkflow: ) return employee + @pytest.fixture + def supply_replacement_user(self, db, supply_department): + employee = Employee.objects.create_user( + email="supply-replacement@example.com", + password="testpass123", + phone_number="+79998888893", + first_name="Новый", + last_name="Исполнитель", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=employee, + department=supply_department, + is_active=True, + ) + return employee + + @pytest.fixture + def procurement_execute_user(self, db): + employee = Employee.objects.create_user( + email="procurement-execute@example.com", + password="testpass123", + phone_number="+79998888894", + first_name="Права", + last_name="Закупок", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + employee.user_permissions.add( + Permission.objects.get(codename="execute_procurement") + ) + return employee + @pytest.fixture def outsider(self, db): return Employee.objects.create_user( @@ -1053,6 +1089,42 @@ class TestProcessingDepartmentWorkflow: is True ) + def test_processing_permission_flags_allow_reassignment_for_other_member( + self, api_client, user, supply_user, supply_replacement_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': processing_request.id}, + ) + + api_client.force_authenticate(user=supply_user) + executor_response = api_client.get(detail_url) + assert executor_response.status_code == status.HTTP_200_OK + assert executor_response.data["can_current_user_start_work"] is False + + api_client.force_authenticate(user=supply_replacement_user) + replacement_response = api_client.get(detail_url) + assert replacement_response.status_code == status.HTTP_200_OK + assert replacement_response.data["can_current_user_start_work"] is True + + 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 + def test_outsider_cannot_start_processing_department_request( self, api_client, outsider, processing_request, procurement_item_factory ): @@ -1118,6 +1190,264 @@ class TestProcessingDepartmentWorkflow: assert processing_request.executor == supply_role_user assert processing_request.status == ProcurementStatus.IN_PROGRESS + def test_processing_department_user_can_reassign_in_progress_request( + self, api_client, supply_user, supply_replacement_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + previous_started_at = timezone.now() + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = previous_started_at + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=supply_replacement_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_replacement_user + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.started_at > previous_started_at + assert response.data["executor"] == supply_replacement_user.id + assert response.data["executor_name"] == ( + supply_replacement_user.get_full_name() + ) + + def test_available_scope_includes_reassignable_processing_request( + self, api_client, supply_user, supply_replacement_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=supply_replacement_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get(f"{url}?scope=available") + + assert response.status_code == status.HTTP_200_OK + ids = [item['id'] for item in response.data['results']] + assert processing_request.id in ids + + def test_procurement_execute_permission_can_reassign_addressed_request( + self, api_client, supply_user, procurement_execute_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=procurement_execute_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 == procurement_execute_user + + def test_processing_department_role_user_can_reassign_in_progress_request( + self, api_client, supply_user, supply_role_user, + processing_request, procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + 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_reassign_in_progress_request_notifies_previous_executor_only( + self, api_client, user, supply_user, supply_replacement_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, + ) + + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + sent.clear() + api_client.force_authenticate(user=supply_replacement_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 + reassignment_notifications = [ + item for item in sent + if item["verb"] == "procurement_executor_reassigned" + ] + assert len(reassignment_notifications) == 1 + notification = reassignment_notifications[0] + assert notification["recipient"] == supply_user + assert notification["sender"] == supply_replacement_user + assert notification["data"]["title"] == ( + "Заявку забрал другой сотрудник" + ) + assert notification["description"] == ( + f'{supply_replacement_user.get_full_name()} взял в работу заявку ' + f'"{processing_request.title}".' + ) + assert user not in [ + item["recipient"] for item in reassignment_notifications + ] + assert "procurement_in_progress" not in [ + item["verb"] for item in sent + ] + + def test_current_executor_cannot_reassign_same_request( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=supply_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_400_BAD_REQUEST + processing_request.refresh_from_db() + assert processing_request.executor == supply_user + + def test_outsider_cannot_reassign_in_progress_request( + self, api_client, outsider, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory(request=processing_request) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = timezone.now() + processing_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=outsider) + url = reverse( + 'api:v1:procurement:procurementrequest-start-work', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + processing_request.refresh_from_db() + assert processing_request.executor == supply_user + + def test_non_processing_in_progress_request_cannot_be_reassigned( + self, api_client, user, staff_user, procurement_request, + procurement_item + ): + procurement_request.status = ProcurementStatus.IN_PROGRESS + procurement_request.executor = staff_user + procurement_request.started_at = timezone.now() + procurement_request.save( + update_fields=[ + "status", + "executor", + "started_at", + "updated_at", + ], + ) + api_client.force_authenticate(user=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_400_BAD_REQUEST + procurement_request.refresh_from_db() + assert procurement_request.executor == staff_user + def test_processing_department_role_user_can_manage_items( self, api_client, supply_role_user, processing_request, procurement_item_factory diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 1587dce8..f08432f6 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -191,6 +191,7 @@ function ProcurementRequestActionButtons({ const isPending = status === "pending"; const canStartWork = Boolean(request.can_current_user_start_work); const isInProgress = status === "in_progress"; + const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу"; const canApproveThis = Boolean(request.can_current_user_approve); const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval); const buttonClass = (variantClass: string) => @@ -266,11 +267,11 @@ function ProcurementRequestActionButtons({ type="button" onClick={() => onStart(request.id)} disabled={busyKey === `start-${request.id}`} - title="Взять в работу" + title={startWorkLabel} className={buttonClass("app-action-primary")} > - {label("Взять в работу")} + {label(startWorkLabel)} ) : null} {isInProgress && isExecutor ? ( diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts index 794d79ed..963680f6 100644 --- a/frontend/src/lib/verbTranslations.ts +++ b/frontend/src/lib/verbTranslations.ts @@ -44,6 +44,7 @@ export const VERB_CATEGORIES: Record = { procurement_approved: 'Закупки', procurement_rejected: 'Закупки', procurement_in_progress: 'Закупки', + procurement_executor_reassigned: 'Закупки', procurement_completed: 'Закупки', procurement_cancelled: 'Закупки', procurement_item_updated: 'Закупки', @@ -99,6 +100,7 @@ export const VERB_NAMES: Record = { procurement_approved: 'Закупка одобрена', procurement_rejected: 'Закупка отклонена', procurement_in_progress: 'Закупка взята в работу', + procurement_executor_reassigned: 'Заявку забрал другой сотрудник', procurement_completed: 'Закупка завершена', procurement_cancelled: 'Закупка отменена', procurement_item_updated: 'Позиция закупки изменена',