From 947883243c66b6eaf9c81b82e2f0c05b26a0fdc2 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 11 Jun 2026 19:35:16 +0300 Subject: [PATCH] Show procurement arrival notice timestamp --- backend/api/v1/procurement/serializers.py | 8 +++ backend/api/v1/procurement/views.py | 41 +++++++++++++- .../procurement/test_procurement_requests.py | 54 +++++++++++++++++++ frontend/src/app/globals.css | 6 +++ frontend/src/app/procurement/page.tsx | 11 +++- frontend/src/types/api.ts | 1 + 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 6a5dbfb1..b58e1cbe 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -131,6 +131,10 @@ def _effective_received_quantity(item): class ProcurementRequestSummaryMixin(serializers.Serializer): is_viewed = serializers.SerializerMethodField() + last_arrival_notice_at = serializers.DateTimeField( + read_only=True, + allow_null=True, + ) next_expected_delivery_date = serializers.SerializerMethodField() items_total_count = serializers.SerializerMethodField() items_received_count = serializers.SerializerMethodField() @@ -545,6 +549,7 @@ class ProcurementRequestListSerializer( "fulfillment_status", "fulfillment_status_display", "is_viewed", + "last_arrival_notice_at", "total_cost", "items_count", "next_expected_delivery_date", @@ -582,6 +587,7 @@ class ProcurementRequestListSerializer( "fulfillment_status", "fulfillment_status_display", "is_viewed", + "last_arrival_notice_at", "items_count", "total_cost", "next_expected_delivery_date", @@ -785,6 +791,7 @@ class ProcurementRequestDetailSerializer( "fulfillment_status", "fulfillment_status_display", "is_viewed", + "last_arrival_notice_at", "total_cost", "actual_cost", "next_expected_delivery_date", @@ -820,6 +827,7 @@ class ProcurementRequestDetailSerializer( "completed_at", "is_editable", "is_viewed", + "last_arrival_notice_at", "total_cost", "next_expected_delivery_date", "items_total_count", diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index a01d5f3b..100cc9be 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -35,6 +35,7 @@ from procurement.notifications.handlers import ( notify_request_arrival, notify_request_comment, ) +from procurement.notifications.config import NotificationVerbs from communications import comments_helpers from communications.models import Message from procurement.services import ProcurementApprovalResolver @@ -100,6 +101,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "complete", "cancel", "mark_all_received", + "notify_arrival", "set_viewed", ]: return ProcurementRequestDetailSerializer @@ -122,16 +124,19 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): from django.contrib.contenttypes.models import ContentType from django.db.models import ( BooleanField, + CharField, Count, + DateTimeField, Exists, IntegerField, OuterRef, Subquery, Value, ) - from django.db.models.functions import Coalesce + from django.db.models.functions import Cast, Coalesce from django.utils import timezone from communications.models import Chat + from notifications.models import Notification ct = ContentType.objects.get_for_model(ProcurementRequest) comments_sub = ( @@ -147,6 +152,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) .values("msg_count")[:1] ) + arrival_notice_sub = ( + Notification.objects.filter( + verb=NotificationVerbs.ARRIVAL_NOTICE, + target_content_type=ct, + target_object_id=Cast( + OuterRef("pk"), + output_field=CharField(), + ), + ) + .order_by("-timestamp") + .values("timestamp")[:1] + ) user = self.request.user viewed_annotation = ( @@ -168,6 +185,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): output_field=IntegerField(), ), is_viewed_for_current_user=viewed_annotation, + last_arrival_notice_at=Subquery( + arrival_notice_sub, + output_field=DateTimeField(), + ), ) scope = self.request.query_params.get("scope", None) period = self.request.query_params.get("period", None) @@ -919,6 +940,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) notify_request_arrival(procurement_request, actor=request.user) + from django.contrib.contenttypes.models import ContentType + from notifications.models import Notification + + request_content_type = ContentType.objects.get_for_model( + ProcurementRequest, + ) + latest_notice_at = ( + Notification.objects.filter( + verb=NotificationVerbs.ARRIVAL_NOTICE, + target_content_type=request_content_type, + target_object_id=str(procurement_request.pk), + ) + .order_by("-timestamp") + .values_list("timestamp", flat=True) + .first() + ) + if latest_notice_at: + procurement_request.last_arrival_notice_at = latest_notice_at serializer = self.get_serializer(procurement_request) return Response(serializer.data) diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index ffad429b..75b02645 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest from django.contrib.auth.models import Permission from django.urls import reverse +from django.utils.dateparse import parse_datetime from django.utils import timezone from push_notifications.models import WebPushDevice from rest_framework import status @@ -3117,6 +3118,59 @@ class TestProcessingDepartmentWorkflow: for item in arrival_notifications ) + def test_notify_arrival_exposes_last_notice_timestamp( + self, api_client, user, supply_user, processing_request + ): + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=supply_user) + url = reverse( + 'api:v1:procurement:procurementrequest-notify-arrival', + kwargs={'pk': processing_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + notice = Notification.objects.get( + recipient=user, + verb="procurement_arrival_notice", + target_object_id=str(processing_request.id), + ) + assert ( + parse_datetime(response.data["last_arrival_notice_at"]) + == notice.timestamp + ) + + list_response = api_client.get( + reverse('api:v1:procurement:procurementrequest-list'), + ) + assert list_response.status_code == status.HTTP_200_OK + list_payload = list_response.json() + list_items = list_payload.get("results", list_payload) + list_item = next( + item for item in list_items + if item["id"] == processing_request.id + ) + assert ( + parse_datetime(list_item["last_arrival_notice_at"]) + == notice.timestamp + ) + + detail_response = api_client.get( + reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': processing_request.id}, + ), + ) + assert detail_response.status_code == status.HTTP_200_OK + assert ( + parse_datetime(detail_response.data["last_arrival_notice_at"]) + == notice.timestamp + ) + def test_notify_arrival_is_not_available_after_request_completed( self, api_client, supply_user, processing_request, monkeypatch ): diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index abe5bdf7..6211c73f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -582,6 +582,12 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { color: #c2410c; } +.app-feedback-notify { + border: 1px solid color-mix(in srgb, #8b5cf6 24%, var(--border-subtle)); + background: color-mix(in srgb, #8b5cf6 14%, var(--surface-primary)); + color: #7c3aed; +} + .attendance-matrix-cell--technical { background: color-mix(in srgb, #ef4444 14%, var(--surface-primary)); color: #b91c1c; diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 83ebdf08..6319ffc4 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -37,7 +37,7 @@ import { } from "lucide-react"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; -import { formatDate, formatMoney } from "@/lib/shared"; +import { formatDate, formatDateTime, formatMoney } from "@/lib/shared"; import { useProcurementPage } from "@/hooks/useProcurementPage"; import { Modal } from "@/components/ui"; import { getProcurementQuantityUnitLabel, getProcurementUnitOptions } from "@/lib/procurementUnits"; @@ -117,6 +117,7 @@ const periodOptions = [ ]; const fmt = formatDate; +const fmtDateTime = formatDateTime; const money = formatMoney; type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete"; @@ -859,6 +860,7 @@ export default function ProcurementPage() { resolvedDetail.quantity_unit_label ?? req.quantity_unit_label, ); const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null; + const lastArrivalNoticeAt = resolvedDetail.last_arrival_notice_at ?? req.last_arrival_notice_at ?? null; const approvalsCount = resolvedDetail.approvals?.length ?? 0; const requestDateLabel = getRequestListDateLabel(resolvedDetail, st); @@ -1020,7 +1022,7 @@ export default function ProcurementPage() { не назначен )} - {(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && ( + {(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate || lastArrivalNoticeAt) && (
{fulfillmentStatusDisplay ? ( @@ -1032,6 +1034,11 @@ export default function ProcurementPage() { {fmt(nextExpectedDeliveryDate)} ) : null} + {lastArrivalNoticeAt ? ( + + Уведомлён {fmtDateTime(lastArrivalNoticeAt)} + + ) : null} {itemsTotalCount > 0 && ( {itemsTotalCount} поз. diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index eee53bde..dc7fbc15 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -595,6 +595,7 @@ export interface ProcurementRequest { total_cost?: string | number; total_estimated_cost?: string | number; items_count?: number; + last_arrival_notice_at?: string | null; next_expected_delivery_date?: string | null; items_total_count?: number; items_received_count?: number;