Show procurement arrival notice timestamp

This commit is contained in:
lizerk 2026-06-11 19:35:16 +03:00
parent b16b74c060
commit 947883243c
6 changed files with 118 additions and 3 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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
):

View File

@ -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;

View File

@ -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() {
<span>не назначен</span>
)}
</div>
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate || lastArrivalNoticeAt) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{fulfillmentStatusDisplay ? (
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
@ -1032,6 +1034,11 @@ export default function ProcurementPage() {
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
</span>
) : null}
{lastArrivalNoticeAt ? (
<span className="app-feedback-notify inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Уведомлён {fmtDateTime(lastArrivalNoticeAt)}
</span>
) : null}
{itemsTotalCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Package size={11} /> {itemsTotalCount} поз.

View File

@ -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;