From c0092437e19391684ff63bee40e603a134ebc274 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 14:02:42 +0300 Subject: [PATCH] improve procurement request workspace --- backend/api/v1/procurement/views.py | 153 +++++++--- backend/notifications/api/serializers.py | 8 + backend/notifications/cache.py | 24 ++ .../0018_procurementitemattachment_comment.py | 25 ++ backend/procurement/models.py | 8 + .../procurement/notifications/read_service.py | 33 ++- .../notifications/test_notifications_api.py | 14 +- .../procurement/test_procurement_requests.py | 109 +++++++- frontend/src/app/globals.css | 53 +++- frontend/src/app/procurement/page.tsx | 204 +++++++++----- .../ProcurementCommentAttachment.tsx | 127 +++++++++ .../ProcurementRequestDetailContent.tsx | 264 ++++++++++++------ .../src/contexts/NotificationsContext.tsx | 29 ++ frontend/src/hooks/useProcurementPage.ts | 44 ++- frontend/src/lib/api/notifications.ts | 4 + frontend/src/lib/api/procurement.ts | 17 +- frontend/src/types/api.ts | 1 + 17 files changed, 906 insertions(+), 211 deletions(-) create mode 100644 backend/procurement/migrations/0018_procurementitemattachment_comment.py create mode 100644 frontend/src/components/procurement/ProcurementCommentAttachment.tsx diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 9dc158f9..81f21aac 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -7,12 +7,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.http import FileResponse, Http404 from drf_spectacular.utils import OpenApiParameter, extend_schema -from django.db.models import Q +from django.db.models import Prefetch, Q from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action -from rest_framework.parsers import MultiPartParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from employees.models import Department, EmployeeDepartment @@ -1508,6 +1508,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "create_equipment", "link_equipment", "comments", + "mark_comment_notifications_read", "delete_comment", "report_issue", "confirm_ordered", @@ -2191,13 +2192,33 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): self._check_item_edit_permission(instance) instance.delete() - @action(detail=True, methods=["get", "post"]) + def _item_comment_payload(self, item, message): + attachments = [ + attachment + for attachment in message.procurement_item_attachments.all() + if attachment.item_id == item.id + ] + return { + "id": message.id, + "item": item.id, + "request": item.request_id, + "author": EmployeeBriefSerializer(message.author).data, + "text": message.content, + "created_at": message.created_at, + "attachments": ProcurementItemAttachmentSerializer( + attachments, + many=True, + context=self.get_serializer_context(), + ).data, + } + + @action( + detail=True, + methods=["get", "post"], + parser_classes=[JSONParser, MultiPartParser, FormParser], + ) def comments(self, request, pk=None): """Список/создание комментариев для позиции заявки.""" - from api.v1.employees.serializers.employee import ( - EmployeeBriefSerializer, - ) - item = self.get_object() if not self._can_access_item_comments(request.user, item.request): return Response( @@ -2206,38 +2227,68 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): ) if request.method in ("GET", "HEAD"): - messages = comments_helpers.get_comments(item) - comments_data = [] - for msg in messages: - author_ser = EmployeeBriefSerializer(msg.author) - comments_data.append( - { - "id": msg.id, - "item": item.id, - "request": item.request_id, - "author": author_ser.data, - "text": msg.content, - "created_at": msg.created_at, - } + messages = comments_helpers.get_comments(item).prefetch_related( + Prefetch( + "procurement_item_attachments", + queryset=ProcurementItemAttachment.objects.filter( + item=item, + ).select_related("uploaded_by"), ) - return Response(comments_data) + ) + return Response([ + self._item_comment_payload(item, message) + for message in messages + ]) text = request.data.get("text", "").strip() - if not text: + files = request.FILES.getlist("files") + if not files and request.FILES.get("file"): + files = [request.FILES["file"]] + if not text and not files: return Response( - {"text": ["Это поле не может быть пустым."]}, + {"detail": "Введите комментарий или добавьте файл."}, status=status.HTTP_400_BAD_REQUEST, ) - message = comments_helpers.create_comment( - obj=item, - author=request.user, - content=text, - ) - item_links, added_links = merge_comment_links(item.links, text) - if added_links: - item.links = item_links - item.save(update_fields=["links"]) + with transaction.atomic(): + message = comments_helpers.create_comment( + obj=item, + author=request.user, + content=text, + ) + created_attachments = [] + for uploaded_file in files: + attachment = ProcurementItemAttachment.objects.create( + item=item, + comment=message, + file=uploaded_file, + file_name=Path(uploaded_file.name.replace("\\", "/")).name, + file_size=uploaded_file.size, + mime_type=uploaded_file.content_type or "", + uploaded_by=request.user, + ) + created_attachments.append(attachment) + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.ATTACHMENT_ADDED, + object_kind=ProcurementActivityObjectKind.ATTACHMENT, + object_id=attachment.pk, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "file_name": attachment.file_name, + "comment_id": message.pk, + }, + ) + if created_attachments: + message.has_attachments = True + message.save(update_fields=["has_attachments"]) + + item_links, added_links = merge_comment_links(item.links, text) + if added_links: + item.links = item_links + item.save(update_fields=["links"]) notify_item_comment( item, message, @@ -2256,19 +2307,35 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "added_links": added_links, }, ) - author_ser = EmployeeBriefSerializer(message.author) + payload = self._item_comment_payload(item, message) + payload.update({ + "item_links": item_links, + "added_links": added_links, + }) + return Response(payload, status=status.HTTP_201_CREATED) + + @extend_schema( + responses=ProcurementNotificationReadResponseSerializer, + summary="Отметить уведомления о комментариях позиции как прочитанные", + ) + @action( + detail=True, + methods=["post"], + url_path="comment-notifications/read", + ) + def mark_comment_notifications_read(self, request, pk=None): + item = self.get_object() + self._check_item_read_permission(item) + result = ProcurementNotificationReadService().mark_item_comment_notifications_as_read( + user=request.user, + item=item, + ) return Response( { - "id": message.id, - "item": item.id, - "request": item.request_id, - "author": author_ser.data, - "text": message.content, - "created_at": message.created_at, - "item_links": item_links, - "added_links": added_links, - }, - status=status.HTTP_201_CREATED, + "status": "success", + "count": result.count, + "notification_ids": result.notification_ids, + } ) @extend_schema( diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index 9c2cda11..0f9dcc9b 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer): unread = serializers.IntegerField() +class UnreadSummaryProcurementItemCommentSerializer(serializers.Serializer): + item_id = serializers.IntegerField() + unread = serializers.IntegerField() + + class UnreadSummaryTaskBoardSerializer(serializers.Serializer): board_id = serializers.IntegerField() unread = serializers.IntegerField() @@ -65,6 +70,9 @@ class UnreadSummaryResponseSerializer(serializers.Serializer): total = serializers.IntegerField() verbs = UnreadSummaryVerbSerializer(many=True) procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True) + procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer( + many=True, + ) task_boards = UnreadSummaryTaskBoardSerializer(many=True) regulation_departments = UnreadSummaryRegulationDepartmentSerializer( many=True, diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py index 9a29138a..f8a3e836 100644 --- a/backend/notifications/cache.py +++ b/backend/notifications/cache.py @@ -72,6 +72,29 @@ def _build_procurement_request_counts(user_id): ] +def _build_procurement_item_comment_counts(user_id): + from .models import Notification + + counts = {} + notifications = Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb="procurement_item_commented", + ).only("data") + + for notification in notifications: + data = notification.data if isinstance(notification.data, dict) else {} + item_id = _coerce_positive_int(data.get("item_id")) + if item_id is not None: + counts[item_id] = counts.get(item_id, 0) + 1 + + return [ + {"item_id": item_id, "unread": unread} + for item_id, unread in sorted(counts.items()) + ] + + def _build_task_board_counts(user_id): from django.contrib.contenttypes.models import ContentType @@ -209,6 +232,7 @@ def build_unread_summary(user_id): "total": sum(item["unread"] for item in verbs), "verbs": verbs, "procurement_requests": _build_procurement_request_counts(user_id), + "procurement_item_comments": _build_procurement_item_comment_counts(user_id), "task_boards": _build_task_board_counts(user_id), "regulation_departments": regulation_counts["departments"], "regulation_company_unread": regulation_counts["company_unread"], diff --git a/backend/procurement/migrations/0018_procurementitemattachment_comment.py b/backend/procurement/migrations/0018_procurementitemattachment_comment.py new file mode 100644 index 00000000..d2982816 --- /dev/null +++ b/backend/procurement/migrations/0018_procurementitemattachment_comment.py @@ -0,0 +1,25 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communications", "0039_alter_chat_context_object_id_bigint"), + ("procurement", "0017_add_item_defective_at"), + ] + + operations = [ + migrations.AddField( + model_name="procurementitemattachment", + name="comment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="procurement_item_attachments", + to="communications.message", + verbose_name="Комментарий", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 4f49217e..352a6ee7 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -680,6 +680,14 @@ class ProcurementItemAttachment(models.Model): related_name="attachments", verbose_name="Позиция", ) + comment = models.ForeignKey( + "communications.Message", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="procurement_item_attachments", + verbose_name="Комментарий", + ) file = models.FileField( "Файл", upload_to=procurement_item_attachment_upload_to, diff --git a/backend/procurement/notifications/read_service.py b/backend/procurement/notifications/read_service.py index 3278c7bc..3844521c 100644 --- a/backend/procurement/notifications/read_service.py +++ b/backend/procurement/notifications/read_service.py @@ -8,7 +8,7 @@ from django.utils import timezone from notifications.cache import invalidate_unread_summary from notifications.models import Notification from notifications.realtime import send_notifications_read_all_event -from procurement.models import ProcurementRequest +from procurement.models import ProcurementItem, ProcurementRequest @dataclass(frozen=True) @@ -34,7 +34,32 @@ class ProcurementNotificationReadService: queryset = self._get_unread_queryset( user_id=user.id, procurement_request=procurement_request, + ).exclude(verb="procurement_item_commented") + return self._mark_queryset_as_read(user_id=user.id, queryset=queryset) + + def mark_item_comment_notifications_as_read( + self, + *, + user, + item: ProcurementItem, + ) -> ProcurementNotificationReadResult: + item_id = item.id + queryset = Notification.objects.filter( + recipient_id=user.id, + unread=True, + deleted=False, + verb="procurement_item_commented", + ).filter( + Q(data__item_id=item_id) | Q(data__item_id=str(item_id)) ) + return self._mark_queryset_as_read(user_id=user.id, queryset=queryset) + + def _mark_queryset_as_read( + self, + *, + user_id: int, + queryset, + ) -> ProcurementNotificationReadResult: notification_ids = list(queryset.values_list("id", flat=True)) if not notification_ids: @@ -51,11 +76,11 @@ class ProcurementNotificationReadService: timestamp_read=read_at, ) - invalidate_unread_summary(user.id) + invalidate_unread_summary(user_id) def notify_after_commit(): - invalidate_unread_summary(user.id) - send_notifications_read_all_event(user.id, notification_ids) + invalidate_unread_summary(user_id) + send_notifications_read_all_event(user_id, notification_ids) transaction.on_commit(notify_after_commit) diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index e6899257..8a320bb6 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -34,6 +34,12 @@ class TestNotificationUnreadSummary: description="Еще закупка", data={"request_id": "12"}, ) + Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + description="Комментарий к позиции закупки", + data={"request_id": 10, "item_id": 101}, + ) Notification.objects.create( recipient=user, verb="chat_new_message", @@ -96,12 +102,13 @@ class TestNotificationUnreadSummary: response = api_client.get("/api/v1/notifications/summary/") assert response.status_code == 200 - assert response.data["total"] == 9 + assert response.data["total"] == 10 assert { item["verb"]: item["unread"] for item in response.data["verbs"] } == { "procurement_department_request": 2, + "procurement_item_commented": 1, "chat_new_message": 1, "task_assigned": 1, "task_comment": 1, @@ -110,9 +117,12 @@ class TestNotificationUnreadSummary: "regulation_signed_all": 1, } assert response.data["procurement_requests"] == [ - {"request_id": 10, "unread": 1}, + {"request_id": 10, "unread": 2}, {"request_id": 12, "unread": 1}, ] + assert response.data["procurement_item_comments"] == [ + {"item_id": 101, "unread": 1}, + ] assert response.data["task_boards"] == [ {"board_id": 21, "unread": 2}, {"board_id": 22, "unread": 1}, diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 5ff9b3ab..3facc1b0 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -997,6 +997,63 @@ class TestProcurementItemComments: ] assert comment_activities.first().metadata["item_id"] == procurement_item.id + def test_item_comment_files_are_added_to_item_and_returned_with_comment( + self, + api_client, + user, + procurement_item, + settings, + tmp_path, + ): + settings.MEDIA_ROOT = tmp_path + api_client.force_authenticate(user=user) + comments_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': procurement_item.id}, + ) + image_content = b'\x89PNG\r\n\x1a\nprocurement comment image' + + create_response = api_client.post( + comments_url, + { + 'text': '', + 'files': SimpleUploadedFile( + 'position-photo.png', + image_content, + content_type='image/png', + ), + }, + format='multipart', + ) + + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data['text'] == '' + assert len(create_response.data['attachments']) == 1 + attachment_data = create_response.data['attachments'][0] + assert attachment_data['file_name'] == 'position-photo.png' + assert attachment_data['mime_type'] == 'image/png' + + attachment = ProcurementItemAttachment.objects.get( + pk=attachment_data['id'], + ) + assert attachment.item_id == procurement_item.id + assert attachment.comment_id == create_response.data['id'] + + comments_response = api_client.get(comments_url) + assert comments_response.status_code == status.HTTP_200_OK + assert comments_response.data[0]['attachments'][0]['id'] == attachment.id + + attachments_response = api_client.get(reverse( + 'api:v1:procurement:procurementitem-attachments', + kwargs={'pk': procurement_item.id}, + )) + assert attachments_response.status_code == status.HTTP_200_OK + assert attachments_response.data[0]['id'] == attachment.id + + download_response = api_client.get(attachment_data['download_url']) + assert download_response.status_code == status.HTTP_200_OK + assert b''.join(download_response.streaming_content) == image_content + def test_comment_links_are_added_to_item_without_duplicates( self, api_client, user, procurement_item ): @@ -6063,7 +6120,8 @@ class TestProcurementRequestNotificationRead: """Тесты прочтения уведомлений, связанных с конкретной закупкой.""" def test_marks_only_current_request_procurement_notifications_read( - self, api_client, user, department, procurement_request + self, api_client, user, department, procurement_request, + procurement_item_factory, ): request_content_type = ContentType.objects.get_for_model( ProcurementRequest, @@ -6088,6 +6146,17 @@ class TestProcurementRequestNotificationRead: target_object_id=str(procurement_request.id), data={}, ) + procurement_item = procurement_item_factory( + request=procurement_request, + ) + item_comment_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={ + "request_id": procurement_request.id, + "item_id": procurement_item.id, + }, + ) other_request_notification = Notification.objects.create( recipient=user, verb="procurement_department_request", @@ -6124,16 +6193,54 @@ class TestProcurementRequestNotificationRead: matching_by_data.refresh_from_db() matching_by_target.refresh_from_db() + item_comment_notification.refresh_from_db() other_request_notification.refresh_from_db() non_procurement_notification.refresh_from_db() already_read_notification.refresh_from_db() assert matching_by_data.unread is False assert matching_by_target.unread is False + assert item_comment_notification.unread is True assert other_request_notification.unread is True assert non_procurement_notification.unread is True assert already_read_notification.unread is False + def test_marks_item_comment_notifications_only_after_opening_item_comments( + self, api_client, user, procurement_request, procurement_item_factory + ): + item = procurement_item_factory(request=procurement_request) + other_item = procurement_item_factory(request=procurement_request) + matching_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={"request_id": procurement_request.id, "item_id": item.id}, + ) + other_item_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={ + "request_id": procurement_request.id, + "item_id": other_item.id, + }, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-mark-comment-notifications-read', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["notification_ids"] == [matching_notification.id] + + matching_notification.refresh_from_db() + other_item_notification.refresh_from_db() + assert matching_notification.unread is False + assert other_item_notification.unread is True + # ============================================================================== # ТЕСТЫ УДАЛЕНИЯ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a9ee0306..918532b1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -553,16 +553,22 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { grid-column: 1 / -1; } +.procurement-detail-support-grid { + display: grid; + align-items: start; + gap: 0.75rem; +} + .procurement-items-table-row { display: grid; grid-template-columns: minmax(0, 1.5fr) - minmax(0, 0.55fr) - minmax(0, 0.9fr) + minmax(0, 0.7fr) + minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 1fr) - minmax(0, 0.9fr) - minmax(0, 1.15fr); + minmax(0, 1.15fr) + minmax(0, 0.9fr); } .procurement-items-table-row > * { @@ -574,16 +580,55 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { border-right: 0; } +.procurement-item-wide-rowgroup { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +.procurement-item-wide-main { + grid-row: 1; +} + +.procurement-item-wide-processing { + grid-row: 1; +} + +.procurement-item-wide-details { + display: grid; + grid-row: 2; + grid-template-columns: minmax(0, 1fr); +} + +.procurement-item-wide-description { + grid-row: 2; +} + +.procurement-item-wide-comments { + grid-row: 3; +} + @container (min-width: 44rem) { .procurement-detail-layout-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .procurement-detail-support-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + } } @container (min-width: 74rem) { .procurement-detail-layout-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .procurement-detail-support-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .procurement-detail-support-comments { + grid-column: span 2 / span 2; + } } .payroll-admin-toolbar-responsive { diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 120a1ead..17a53d82 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -107,6 +107,13 @@ const getReceivedProgressClass = (received: number, total: number) => { return received >= total ? "app-feedback-success" : "app-feedback-warning"; }; +function newestCommentsFirst(comments: T[]): T[] { + return [...comments].sort((left, right) => { + const timeDifference = new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime(); + return timeDifference || Number(right.id || 0) - Number(left.id || 0); + }); +} + const canConfirmAllReceived = (request: ProcurementRequest, isAuthor: boolean) => { const status = String(request.status || "").toLowerCase(); const itemsCount = Number(request.items_total_count ?? request.items_count ?? request.items?.length ?? 0); @@ -447,6 +454,7 @@ export default function ProcurementPage() { expandedItemComments, itemCommentsMap, itemCommentDrafts, + itemCommentFiles, setItemCommentDrafts, expandedIds, expandedComments, @@ -518,9 +526,11 @@ export default function ProcurementPage() { scope, scopeCounts, unreadProcurementRequestCounts, + unreadProcurementItemCommentCounts, handleAddComment, handleDeleteComment, handleAddItemComment, + handleItemCommentFilesChange, handleDeleteItemComment, handleDownloadItemAttachment, handleUploadItemAttachments, @@ -813,12 +823,6 @@ export default function ProcurementPage() { } }, [desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]); - useEffect(() => { - if (!desktopWideLayout || filteredRequests.length === 0) return; - if (detailModalId && filteredRequests.some((request) => request.id === detailModalId)) return; - void openDetailModal(filteredRequests[0].id); - }, [desktopWideLayout, detailModalId, filteredRequests, openDetailModal]); - useEffect(() => { if (typeof window === "undefined") return; if (initialRequestParamHandledRef.current) return; @@ -1213,8 +1217,8 @@ export default function ProcurementPage() { {/* ══════════ Wide master-detail workspace ══════════ */}
-