From e190affc7cbb8bb9069681a1f79c6ea40b4f27bf Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 6 Jul 2026 15:45:17 +0300 Subject: [PATCH] feat: link procurement requests with tasks --- backend/api/v1/procurement/serializers.py | 59 ++ backend/api/v1/procurement/views.py | 70 +++ backend/api/v1/tasks/serializers.py | 102 +++ backend/api/v1/tasks/views.py | 181 ++++++ backend/tasks/access.py | 37 ++ .../0007_add_procurement_request_link_kind.py | 41 ++ backend/tasks/models.py | 1 + .../procurement/test_procurement_requests.py | 98 +++ backend/tests/api/v1/tasks/test_tasks_api.py | 93 +++ frontend/src/app/procurement/page.tsx | 80 ++- frontend/src/app/tasks/page.tsx | 157 ++++- .../procurement/ProcurementTaskLinks.tsx | 585 ++++++++++++++++++ frontend/src/lib/api/tasks.ts | 14 + frontend/src/types/api.ts | 35 +- 14 files changed, 1544 insertions(+), 9 deletions(-) create mode 100644 backend/tasks/migrations/0007_add_procurement_request_link_kind.py create mode 100644 frontend/src/components/procurement/ProcurementTaskLinks.tsx diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index fadb7952..59f35dce 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -168,6 +168,7 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): total_ordered_quantity = serializers.SerializerMethodField() total_received_quantity = serializers.SerializerMethodField() quantity_unit_label = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_is_viewed(self, obj): @@ -260,6 +261,60 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): return _unit_display_label(next(iter(units))) return "единиц" + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_linked_tasks(self, obj: ProcurementRequest) -> list[dict]: + prefetched = getattr(obj, "_linked_task_payloads", None) + if prefetched is not None: + return prefetched + + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from django.contrib.contenttypes.models import ContentType + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(ProcurementRequest) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + content_type=content_type, + object_id=obj.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" @@ -637,6 +692,7 @@ class ProcurementRequestListSerializer( "total_received_quantity", "quantity_unit_label", "comments_count", + "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", @@ -675,6 +731,7 @@ class ProcurementRequestListSerializer( "total_received_quantity", "quantity_unit_label", "comments_count", + "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", @@ -887,6 +944,7 @@ class ProcurementRequestDetailSerializer( "required_approval_priorities", "is_editable", "comments_count", + "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", @@ -922,6 +980,7 @@ class ProcurementRequestDetailSerializer( "processing_department_name", "fulfillment_status_display", "comments_count", + "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", "can_current_user_start_work", diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 0ca926b0..4363b3c1 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -1,5 +1,6 @@ """ViewSets для API модуля закупок.""" +from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Q from django.utils import timezone @@ -336,6 +337,75 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): return queryset.distinct() + def _attach_linked_task_payloads( + self, + requests: list[ProcurementRequest], + ) -> None: + """Предзагрузить компактные бейджи задач для списка закупок.""" + if not requests: + return + + user = getattr(self.request, "user", None) + request_ids = [request_obj.id for request_obj in requests] + mapping = {request_id: [] for request_id in request_ids} + + if user and user.is_authenticated: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + + request_ct = ContentType.objects.get_for_model(ProcurementRequest) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + content_type=request_ct, + object_id__in=request_ids, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("object_id", "task__title", "task_id") + ) + + for link in links: + mapping.setdefault(link.object_id, []).append( + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + ) + + for request_obj in requests: + request_obj._linked_task_payloads = mapping.get(request_obj.id, []) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + self._attach_linked_task_payloads(list(page)) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + requests = list(queryset) + self._attach_linked_task_payloads(requests) + serializer = self.get_serializer(requests, many=True) + return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="create-options") def create_options(self, request): """Вернуть настройки выбора отделов для формы создания заявки.""" diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 6195a8b9..047282ca 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -13,6 +13,7 @@ from communications.serialization import serialize_message from communications.utils import user_can_access_chat from documents.models import Document from employees.models import Department +from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Event from api.v1.schedule.serializers import EventSerializer @@ -20,6 +21,7 @@ from tasks.access import ( user_can_access_calendar_event, user_can_access_document, user_can_access_employee_request, + user_can_access_procurement_request, user_can_access_task_board, ) from tasks.models import ( @@ -142,6 +144,7 @@ class TaskSerializer(serializers.ModelSerializer): linked_events_count = serializers.SerializerMethodField() linked_documents_count = serializers.SerializerMethodField() linked_requests_count = serializers.SerializerMethodField() + linked_procurement_requests_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() @@ -170,6 +173,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_events_count", "linked_documents_count", "linked_requests_count", + "linked_procurement_requests_count", "linked_objects_count", "comments_count", "created_at", @@ -187,6 +191,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_events_count", "linked_documents_count", "linked_requests_count", + "linked_procurement_requests_count", "linked_objects_count", "comments_count", "created_at", @@ -225,6 +230,14 @@ class TaskSerializer(serializers.ModelSerializer): kind=TaskLinkedObjectKind.REQUEST, ).count() + @extend_schema_field(serializers.IntegerField()) + def get_linked_procurement_requests_count(self, obj): + if hasattr(obj, "linked_procurement_requests_count"): + return obj.linked_procurement_requests_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): @@ -606,3 +619,92 @@ class TaskLinkedRequestSerializer(serializers.ModelSerializer): def get_request_content_type(): return ContentType.objects.get_for_model(EmployeeRequest) + + +class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + procurement_request = serializers.SerializerMethodField() + procurement_request_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "procurement_request_id", + "procurement_request", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_procurement_request(self, obj): + if obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST: + return None + + procurement_request = getattr(obj, "content_object", None) + if procurement_request is None: + return None + + return { + "id": procurement_request.id, + "title": procurement_request.title, + "description": procurement_request.description, + "status": procurement_request.status, + "status_display": procurement_request.get_status_display(), + "urgency": procurement_request.urgency, + "urgency_display": procurement_request.get_urgency_display(), + "fulfillment_status": procurement_request.fulfillment_status, + "fulfillment_status_display": procurement_request.get_fulfillment_status_display(), + "department_id": procurement_request.department_id, + "department_name": ( + procurement_request.department.name + if procurement_request.department_id + else None + ), + "processing_department_id": procurement_request.processing_department_id, + "processing_department_name": ( + procurement_request.processing_department.name + if procurement_request.processing_department_id + else None + ), + "requestor": EmployeeBriefSerializer(procurement_request.requestor).data, + "executor": ( + EmployeeBriefSerializer(procurement_request.executor).data + if procurement_request.executor_id + else None + ), + "total_cost": procurement_request.total_cost, + "items_count": procurement_request.items_count, + "created_at": procurement_request.created_at, + "updated_at": procurement_request.updated_at, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + procurement_request = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST + or procurement_request is None + or not user + ): + return False + return user_can_access_procurement_request(user, procurement_request) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + procurement_request = getattr(obj, "content_object", None) + if procurement_request is None or not self.get_can_open(obj): + return None + return f"/procurement?request={procurement_request.id}" + + +def get_procurement_request_content_type(): + return ContentType.objects.get_for_model(ProcurementRequest) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 98de4f30..083c4a54 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -13,6 +13,7 @@ from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat from documents.models import Document +from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from rest_framework import filters, status, viewsets from rest_framework.decorators import action @@ -25,6 +26,7 @@ from tasks.access import ( user_can_access_calendar_event, user_can_access_document, user_can_access_employee_request, + user_can_access_procurement_request, ) from tasks.models import ( Task, @@ -50,12 +52,14 @@ from .serializers import ( TaskLinkedCalendarEventSerializer, TaskLinkedDocumentSerializer, TaskLinkedMessageSerializer, + TaskLinkedProcurementRequestSerializer, TaskLinkedRequestSerializer, TaskSerializer, create_default_columns, get_calendar_event_content_type, get_document_content_type, get_message_content_type, + get_procurement_request_content_type, get_request_content_type, ) @@ -286,6 +290,15 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), + linked_procurement_requests_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=( + TaskLinkedObjectKind.PROCUREMENT_REQUEST + ), + ), + distinct=True, + ), linked_objects_count=Count("linked_objects", distinct=True), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), @@ -656,6 +669,52 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-procurement-request-tasks") + def linked_procurement_request_tasks(self, request): + procurement_request_id = request.query_params.get("procurement_request_id") + try: + procurement_request_id = int(procurement_request_id) + except (TypeError, ValueError): + return Response( + {"procurement_request_id": ["Укажите ID заявки на закупку."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + procurement_request = ( + ProcurementRequest.objects.select_related( + "department", + "processing_department", + "requestor", + "executor", + ) + .prefetch_related("approvals", "items") + .get(id=procurement_request_id) + ) + except ProcurementRequest.DoesNotExist: + return Response( + {"procurement_request_id": ["Заявка на закупку не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_procurement_request(request.user, procurement_request): + return Response( + {"detail": "Нет доступа к этой заявке на закупку."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + linked_objects__content_type=get_procurement_request_content_type(), + linked_objects__object_id=procurement_request.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( @@ -696,6 +755,16 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _procurement_request_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + @action(detail=True, methods=["get", "post"], url_path="linked-messages") def linked_messages(self, request, pk=None): task = self.get_object() @@ -1038,6 +1107,118 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-procurement-requests") + def linked_procurement_requests(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedProcurementRequestSerializer( + self._procurement_request_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + procurement_request_id = request.data.get("procurement_request_id") + try: + procurement_request_id = int(procurement_request_id) + except (TypeError, ValueError): + return Response( + {"procurement_request_id": ["Укажите ID заявки на закупку."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + procurement_request = ( + ProcurementRequest.objects.select_related( + "department", + "processing_department", + "requestor", + "executor", + ) + .prefetch_related("approvals", "items") + .get(id=procurement_request_id) + ) + except ProcurementRequest.DoesNotExist: + return Response( + {"procurement_request_id": ["Заявка на закупку не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_procurement_request(request.user, procurement_request): + return Response( + {"detail": "Нет доступа к этой заявке на закупку."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + content_type=get_procurement_request_content_type(), + object_id=procurement_request.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedProcurementRequestSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + object_id=procurement_request.id, + metadata={ + "object_label": procurement_request.title, + "object_type": "Заявка на закупку", + }, + ) + send_task_board_update( + task.board, + "linked", + "procurement_request", + procurement_request.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-procurement-requests/(?P\d+)", + ) + def unlink_procurement_request(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._procurement_request_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + procurement_request_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + object_id=procurement_request_id, + metadata={"object_type": "Заявка на закупку"}, + ) + send_task_board_update( + task.board, + "unlinked", + "procurement_request", + procurement_request_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-events") def linked_events(self, request, pk=None): task = self.get_object() diff --git a/backend/tasks/access.py b/backend/tasks/access.py index 6ee167b8..8ce18f4f 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -107,3 +107,40 @@ def user_can_access_employee_request( if request_obj.recipients.filter(id=user.id).exists(): return True return request_obj.cc_users.filter(id=user.id).exists() + + +def user_can_access_procurement_request(user, procurement_request) -> bool: + if not user or not user.is_authenticated or procurement_request is None: + return False + + from procurement.constants import ProcurementStatus + from procurement.services import ProcurementApprovalResolver + + if user.is_staff or user.is_superuser: + return True + if ( + 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") + ): + return True + if procurement_request.requestor_id == user.id: + return True + if procurement_request.executor_id == user.id: + return True + if procurement_request.approvals.filter(approver=user).exists(): + return True + + participant_department_ids = ( + ProcurementApprovalResolver.get_user_department_participant_ids(user) + ) + if procurement_request.department_id in participant_department_ids: + return True + if procurement_request.processing_department_id in participant_department_ids: + return True + + return ( + procurement_request.status == ProcurementStatus.APPROVED + and procurement_request.processing_department_id is None + ) diff --git a/backend/tasks/migrations/0007_add_procurement_request_link_kind.py b/backend/tasks/migrations/0007_add_procurement_request_link_kind.py new file mode 100644 index 00000000..0bdb5d27 --- /dev/null +++ b/backend/tasks/migrations/0007_add_procurement_request_link_kind.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0006_add_request_link_kind"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="object_kind", + field=models.CharField( + blank=True, + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ("procurement_request", "Заявка на закупку"), + ], + max_length=32, + verbose_name="Тип объекта", + ), + ), + migrations.AlterField( + model_name="tasklinkedobject", + name="kind", + field=models.CharField( + choices=[ + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ("procurement_request", "Заявка на закупку"), + ], + max_length=32, + verbose_name="Тип связи", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 7dcb99ed..75b3b60f 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -178,6 +178,7 @@ class TaskLinkedObjectKind(models.TextChoices): CALENDAR_EVENT = "calendar_event", "Календарное событие" DOCUMENT = "document", "Документ" REQUEST = "request", "Заявление" + PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку" class TaskActivityAction(models.TextChoices): diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index e1875cc1..91c00cd3 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -42,6 +42,13 @@ from procurement.models import ( ProcurementSettings, ) from procurement.notifications.handlers import notify_new_request +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) from notifications.models import Notification, UserChannelPreferences @@ -278,6 +285,97 @@ class TestProcurementRequestList: assert results[0]['id'] == procurement_request.id assert results[0]['title'] == "Закупка ноутбуков" + def test_list_includes_visible_linked_tasks_only( + self, + api_client, + user, + procurement_request, + ): + """Список заявок отдаёт бейджи только доступных пользователю задач.""" + hidden_member = Employee.objects.create_user( + email="hidden-board-member@example.com", + password="testpass123", + phone_number="+79998880003", + first_name="Скрытый", + last_name="Участник", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + visible_board = TaskBoard.objects.create( + name="Видимая доска закупок", + created_by=user, + ) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + visible_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Видимая задача закупки", + created_by=user, + priority="high", + ) + hidden_board = TaskBoard.objects.create( + name="Скрытая доска закупок", + created_by=hidden_member, + ) + hidden_board.members.add(hidden_member) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Новые", + position=1000, + color="#ef4444", + ) + hidden_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Скрытая задача закупки", + created_by=hidden_member, + priority="critical", + ) + request_ct = ContentType.objects.get_for_model(ProcurementRequest) + TaskLinkedObject.objects.create( + task=visible_task, + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + content_type=request_ct, + object_id=procurement_request.id, + created_by=user, + ) + TaskLinkedObject.objects.create( + task=hidden_task, + kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, + content_type=request_ct, + object_id=procurement_request.id, + created_by=hidden_member, + ) + + api_client.force_authenticate(user=user) + response = api_client.get( + reverse('api:v1:procurement:procurementrequest-list') + ) + + assert response.status_code == status.HTTP_200_OK + result = response.data['results'][0] + assert result['id'] == procurement_request.id + assert result['linked_tasks'] == [ + { + 'link_id': visible_task.linked_objects.get().id, + 'id': visible_task.id, + 'title': 'Видимая задача закупки', + 'board_id': visible_board.id, + 'board_name': 'Видимая доска закупок', + 'column_id': visible_column.id, + 'column_name': 'Новые', + 'column_color': '#38bdf8', + 'priority': 'high', + 'priority_display': 'Высокий', + } + ] + def test_list_department_requests( self, api_client, department_head, procurement_request ): diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 5c1d3820..2c5fae10 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -12,6 +12,8 @@ from rest_framework.test import APIClient from communications.models import Chat, ChatMembership, Message from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from procurement.constants import ProcurementStatus, UrgencyLevel +from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Calendar, CalendarRelation, Event from tasks.models import Task, TaskBoard, TaskColumn @@ -588,6 +590,97 @@ def test_task_linked_request_respects_request_access(api_client): ) +def test_task_linked_procurement_request_respects_procurement_access(api_client): + owner = make_user("task-proc-owner@example.com", "+79994440030") + board_member = make_user("task-proc-member@example.com", "+79994440031") + department = Department.objects.create(name="Закупочный отдел тест") + board = TaskBoard.objects.create( + name="Доска с закупками", + created_by=owner, + ) + board.members.add(board_member) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + color="#38bdf8", + ) + task = Task.objects.create( + board=board, + column=column, + title="Проверить закупку", + created_by=owner, + ) + procurement_request = ProcurementRequest.objects.create( + title="Закупка мониторов", + description="Закупка к задаче", + department=department, + requestor=owner, + status=ProcurementStatus.DRAFT, + urgency=UrgencyLevel.HIGH, + ) + + api_client.force_authenticate(user=board_member) + denied_response = api_client.post( + reverse( + "api:v1:tasks:task-linked-procurement-requests", + kwargs={"pk": task.id}, + ), + {"procurement_request_id": procurement_request.id}, + format="json", + ) + assert denied_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + link_response = api_client.post( + reverse( + "api:v1:tasks:task-linked-procurement-requests", + kwargs={"pk": task.id}, + ), + {"procurement_request_id": procurement_request.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["procurement_request"]["title"] == "Закупка мониторов" + assert link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT + assert link_response.data["procurement_request"]["urgency"] == UrgencyLevel.HIGH + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == ( + f"/procurement?request={procurement_request.id}" + ) + + api_client.force_authenticate(user=board_member) + linked_response = api_client.get( + reverse( + "api:v1:tasks:task-linked-procurement-requests", + kwargs={"pk": task.id}, + ) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["procurement_request_id"] == procurement_request.id + assert linked_response.data[0]["procurement_request"]["title"] == ( + "Закупка мониторов" + ) + assert linked_response.data[0]["can_open"] is False + assert linked_response.data[0]["object_url"] is None + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-procurement-request-tasks"), + {"procurement_request_id": procurement_request.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-procurement-request-tasks"), + {"procurement_request_id": procurement_request.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["title"] == "Проверить закупку" + assert reverse_linked_response.data[0]["linked_procurement_requests_count"] == 1 + + def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 1df45703..c30ea4bf 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -6,8 +6,10 @@ import { AppShell } from "../../components/AppShell"; import { useUser } from "@/contexts/UserContext"; import Link from "next/link"; import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent"; +import { ProcurementTaskLinks } from "@/components/procurement/ProcurementTaskLinks"; import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel"; import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import type { ProcurementApprovalOptions, ProcurementApprovalRouteOption, @@ -28,6 +30,7 @@ import { Eye, EyeOff, Filter, + Link2, Loader2, MessageSquare, Package, @@ -503,6 +506,7 @@ export default function ProcurementPage() { const statusFilterRef = useRef(null); const [procurementMenuOpenId, setProcurementMenuOpenId] = useState(null); const [statusFilterOpen, setStatusFilterOpen] = useState(false); + const [taskLinkRequest, setTaskLinkRequest] = useState(null); const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null); const [requestActionComment, setRequestActionComment] = useState(""); const [submitApprovalRequestId, setSubmitApprovalRequestId] = useState(null); @@ -578,6 +582,13 @@ export default function ProcurementPage() { setManualApproverByPriority({}); }, []); + const handleTaskLinked = useCallback(async (requestId: number) => { + await Promise.all([ + loadPage1(), + ensureRequestDetail(requestId), + ]); + }, [ensureRequestDetail, loadPage1]); + const openSubmitApprovalDialog = useCallback(async (requestId: number) => { const requestForSubmit = detailsCache[requestId] || requests.find((request) => request.id === requestId); if (requestForSubmit && !canCurrentUserSubmitRequest(requestForSubmit)) { @@ -1133,7 +1144,6 @@ export default function ProcurementPage() { (resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) || (st === "in_progress" && isExecutor) ); - const hasSecondaryActions = canEditThis || canDeleteThis || canCancelThis; const expanded = expandedIds.has(req.id); const comments = commentsMap[req.id] || []; const commentsOpen = Boolean(expandedComments[req.id]); @@ -1143,8 +1153,8 @@ export default function ProcurementPage() { const rowExpanded = !isViewed && expanded; const rowCommentsOpen = !isViewed && commentsOpen; const showInlineRequestActions = !isViewed && hasInlineRequestActions; - const showSecondaryActions = !isViewed && hasSecondaryActions; - const requestMenuOpen = showSecondaryActions && procurementMenuOpenId === req.id; + const showRequestMenu = true; + const requestMenuOpen = showRequestMenu && procurementMenuOpenId === req.id; const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email); const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : ""; const requestorLink = userLink(req.requestor); @@ -1169,6 +1179,7 @@ export default function ProcurementPage() { const lastArrivalNoticeAt = resolvedDetail.last_arrival_notice_at ?? req.last_arrival_notice_at ?? null; const approvalsCount = resolvedDetail.approvals?.length ?? 0; const requestDateLabel = getRequestListDateLabel(resolvedDetail, st); + const linkedTasks = resolvedDetail.linked_tasks ?? req.linked_tasks ?? []; return (
@@ -1238,6 +1249,22 @@ export default function ProcurementPage() { {requestDateLabel} {getRequestAmount(req) && {money(getRequestAmount(req))}} + {linkedTasks.length > 0 ? ( +
+ {linkedTasks.slice(0, 3).map((task) => ( + + ))} + {linkedTasks.length > 3 ? ( + + +{linkedTasks.length - 3} + + ) : null} +
+ ) : null}
{sMeta.label} - {showSecondaryActions ? ( + {showRequestMenu ? (
{requestMenuOpen ? ( -
+
+ {canCancelThis ? (
+
+ {(selectedRequest.linked_tasks || []).slice(0, 4).map((task) => ( + + ))} + {(selectedRequest.linked_tasks || []).length > 4 ? ( + + +{(selectedRequest.linked_tasks || []).length - 4} + + ) : null} + +
@@ -1681,6 +1741,16 @@ export default function ProcurementPage() { )} + {taskLinkRequest ? ( + setTaskLinkRequest(null)} + onLinked={() => void handleTaskLinked(taskLinkRequest.id)} + /> + ) : null} + void; + disabled?: boolean; +}) { + const request = link.procurement_request; + const canOpen = Boolean(link.can_open && link.object_url); + const amountLabel = request?.total_cost ? formatMoney(request.total_cost) : ""; + const mainContent = ( + <> +
+
+ + + Закупка + + {request?.status_display ? ( + + {request.status_display} + + ) : null} + {request?.urgency_display ? ( + + {request.urgency_display} срочность + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {request?.title || "Заявка на закупку не найдена"} +

+ {request?.created_at ? ( +

+ {formatDate(request.created_at)} +

+ ) : null} +
+ + {request?.description ? ( +

+ {request.description} +

+ ) : null} + +
+ {request?.department_name ? ( + + {request.department_name} + + ) : null} + {request?.processing_department_name ? ( + + {request.processing_department_name} + + ) : null} + {amountLabel ? ( + + {amountLabel} + + ) : null} + {request?.items_count ? ( + + {request.items_count} поз. + + ) : null} + {request?.requestor ? ( + + + {displayUserName(request.requestor)} + + ) : null} + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -1030,6 +1150,7 @@ export default function TasksPage() { const [linkedEvents, setLinkedEvents] = useState([]); const [linkedDocuments, setLinkedDocuments] = useState([]); const [linkedRequests, setLinkedRequests] = useState([]); + const [linkedProcurementRequests, setLinkedProcurementRequests] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); const [taskComments, setTaskComments] = useState([]); @@ -1074,16 +1195,18 @@ export default function TasksPage() { const loadTaskLinkedObjects = useCallback(async (taskId: number) => { setLinkedObjectsLoading(true); try { - const [messages, events, documents, requests] = await Promise.all([ + const [messages, events, documents, requests, procurementRequests] = await Promise.all([ apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), apiClient.getTaskLinkedDocuments(taskId), apiClient.getTaskLinkedRequests(taskId), + apiClient.getTaskLinkedProcurementRequests(taskId), ]); setLinkedMessages(messages); setLinkedEvents(events); setLinkedDocuments(documents); setLinkedRequests(requests); + setLinkedProcurementRequests(procurementRequests); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -1416,7 +1539,7 @@ export default function TasksPage() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length; + const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -1435,6 +1558,7 @@ export default function TasksPage() { setLinkedEvents([]); setLinkedDocuments([]); setLinkedRequests([]); + setLinkedProcurementRequests([]); setTaskActivities([]); setTaskComments([]); setCommentDraft(""); @@ -1481,6 +1605,7 @@ export default function TasksPage() { setLinkedEvents([]); setLinkedDocuments([]); setLinkedRequests([]); + setLinkedProcurementRequests([]); setTaskComments([]); setTaskActivities([]); setCommentDraft(""); @@ -1500,6 +1625,7 @@ export default function TasksPage() { setLinkedEvents([]); setLinkedDocuments([]); setLinkedRequests([]); + setLinkedProcurementRequests([]); setTaskComments([]); setTaskActivities([]); setLinkedObjectsOpen(false); @@ -1898,6 +2024,23 @@ export default function TasksPage() { } }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkProcurementRequestFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskProcurementRequest(viewTask.id, linkId); + await loadTaskLinkedObjects(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (unlinkError) { + setError(getTaskError(unlinkError, "Не удалось убрать связь с закупкой")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const changeViewedTaskColumn = useCallback(async (columnId: number) => { if (!board || !viewTask || saving || viewTask.column === columnId) { setViewColumnMenuOpen(false); @@ -2711,6 +2854,14 @@ export default function TasksPage() { disabled={saving} /> ))} + {linkedProcurementRequests.map((link) => ( + + ))} ) : (
diff --git a/frontend/src/components/procurement/ProcurementTaskLinks.tsx b/frontend/src/components/procurement/ProcurementTaskLinks.tsx new file mode 100644 index 00000000..365a1d9a --- /dev/null +++ b/frontend/src/components/procurement/ProcurementTaskLinks.tsx @@ -0,0 +1,585 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Link2, Loader2, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import { displayUserName, formatMoney } from "@/lib/shared"; +import type { + ProcurementRequest, + TaskBoard, + TaskCard, + TaskPriority, + User, +} from "@/types/api"; + +type ProcurementTaskLinksProps = { + request: ProcurementRequest; + variant?: "section" | "dialog"; + open?: boolean; + onClose?: () => void; + onLinked?: () => void; +}; + +const taskPriorityOptions: { value: TaskPriority; label: string }[] = [ + { value: "low", label: "Низкая" }, + { value: "medium", label: "Средняя" }, + { value: "high", label: "Высокая" }, + { value: "critical", label: "Критическая" }, +]; + +function requestTitle(request: ProcurementRequest) { + return request.title || `Заявка на закупку #${request.id}`; +} + +function getTaskOptionLabel(task: TaskCard) { + return `#${task.id} - ${task.title}`; +} + +function taskMatchesSearch(task: TaskCard, query: string) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + return [ + String(task.id), + task.title, + task.description || "", + task.assignee ? displayUserName(task.assignee) : "", + ] + .join(" ") + .toLowerCase() + .includes(normalized); +} + +export function ProcurementTaskLinks({ + request, + variant = "section", + open = false, + onClose, + onLinked, +}: ProcurementTaskLinksProps) { + const [linkedTasks, setLinkedTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [boards, setBoards] = useState([]); + const [employees, setEmployees] = useState([]); + const [boardId, setBoardId] = useState(""); + const [taskId, setTaskId] = useState(""); + const [taskSearch, setTaskSearch] = useState(""); + const [mode, setMode] = useState<"existing" | "create">("existing"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [detailsOpen, setDetailsOpen] = useState(false); + const [columnId, setColumnId] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [priority, setPriority] = useState("medium"); + const [dueDate, setDueDate] = useState(""); + const [labelIds, setLabelIds] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const selectedBoard = boards.find((board) => board.id === boardId) || null; + const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null; + const selectedColumn = ( + (selectedBoard?.columns || []).find((column) => column.id === columnId) || + (selectedBoard?.columns || []).find((column) => !column.is_archived) || + null + ); + const linkedTaskIds = useMemo( + () => new Set(linkedTasks.map((task) => task.id)), + [linkedTasks], + ); + const dialogMode = variant === "dialog"; + const resolvedModalOpen = dialogMode ? open : linkModalOpen; + + const loadLinkedTasks = useCallback(async () => { + setLoading(true); + try { + const tasks = await apiClient.getProcurementRequestLinkedTasks(request.id); + setLinkedTasks(tasks); + } catch (loadError) { + console.error("Ошибка загрузки связанных задач:", loadError); + toast.error("Не удалось загрузить связанные задачи"); + } finally { + setLoading(false); + } + }, [request.id]); + + useEffect(() => { + void loadLinkedTasks(); + }, [loadLinkedTasks]); + + const resetForm = () => { + setBoardId(""); + setTaskId(""); + setTaskSearch(""); + setMode("existing"); + setTitle(""); + setDescription(""); + setDetailsOpen(false); + setColumnId(""); + setAssigneeId(""); + setPriority("medium"); + setDueDate(""); + setLabelIds([]); + setError(null); + }; + + const prepareLinkModal = useCallback(async () => { + setSaving(true); + setError(null); + const baseTitle = requestTitle(request); + const amount = request.total_cost ? `\nСумма: ${formatMoney(request.total_cost)}` : ""; + setTitle(`Задача по закупке: ${baseTitle}`); + setDescription(`${request.description || ""}${amount}`.trim()); + try { + const [boardsResponse, employeesResponse] = await Promise.all([ + apiClient.getTaskBoards(), + apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), + ]); + const nextBoards = (boardsResponse.results || boardsResponse || []) as TaskBoard[]; + const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[]; + const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null; + const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null; + + setBoards(nextBoards); + setEmployees(nextEmployees); + setBoardId(firstBoard?.id || ""); + setTaskId(firstTask?.id || ""); + setTaskSearch(""); + setColumnId(firstColumn?.id || ""); + setMode(firstTask ? "existing" : "create"); + setDetailsOpen(false); + setAssigneeId(""); + setPriority("medium"); + setDueDate(""); + setLabelIds([]); + } catch (openError) { + setError(openError instanceof Error ? openError.message : "Не удалось загрузить задачи"); + } finally { + setSaving(false); + } + }, [linkedTaskIds, request]); + + const openLinkModal = async () => { + setLinkModalOpen(true); + await prepareLinkModal(); + }; + + const closeLinkModal = () => { + if (saving) return; + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + }; + + useEffect(() => { + if (!dialogMode) return; + if (open) { + void prepareLinkModal(); + } else { + resetForm(); + } + }, [dialogMode, open, prepareLinkModal]); + + const handleBoardChange = (nextBoardId: number | "") => { + setBoardId(nextBoardId); + const nextBoard = boards.find((board) => board.id === nextBoardId) || null; + const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + setTaskId(nextTask?.id || ""); + setTaskSearch(""); + setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || ""); + setLabelIds([]); + if (!nextTask) { + setMode("create"); + } + }; + + const toggleLabel = (labelId: number) => { + setLabelIds((current) => ( + current.includes(labelId) + ? current.filter((id) => id !== labelId) + : [...current, labelId] + )); + }; + + const handleTaskSearchChange = (value: string) => { + setTaskSearch(value); + const nextTask = availableTasks.find((task) => taskMatchesSearch(task, value)); + setTaskId(nextTask?.id || ""); + }; + + const saveLink = async () => { + if (!selectedBoard) return; + if (mode === "existing" && !selectedTask) return; + if (mode === "create" && (!title.trim() || !selectedColumn)) return; + + setSaving(true); + setError(null); + try { + let taskToLink = selectedTask; + if (mode === "create") { + taskToLink = await apiClient.createTask({ + board: selectedBoard.id, + column: selectedColumn?.id, + title: title.trim(), + description: description.trim(), + assignee_id: assigneeId || null, + priority, + due_date: dueDate || null, + label_ids: labelIds, + }); + } + + if (!taskToLink) return; + await apiClient.linkTaskProcurementRequest(taskToLink.id, request.id); + await loadLinkedTasks(); + toast.success("Закупка связана с задачей"); + onLinked?.(); + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Не удалось связать закупку с задачей"); + } finally { + setSaving(false); + } + }; + + const availableTasks = (selectedBoard?.tasks || []).filter((task) => !linkedTaskIds.has(task.id)); + const filteredAvailableTasks = availableTasks.filter((task) => taskMatchesSearch(task, taskSearch)); + + const modal = ( + + + +
+ )} + > +
+
+

Закупка

+

+ {requestTitle(request)} +

+

+ {[request.department_name, request.status_display, request.total_cost ? formatMoney(request.total_cost) : ""].filter(Boolean).join(" · ")} +

+
+ + + +
+ + +
+ + {mode === "existing" ? ( +
+ + +
+ ) : ( +
+ +