diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 10e9fb07..6a5dbfb1 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -24,6 +24,7 @@ from procurement.models import ( MaintenanceRecord, ProcurementItem, ProcurementRequest, + ProcurementRequestView, Supplier, ) from ..employees.serializers import EmployeeBriefSerializer @@ -129,6 +130,7 @@ def _effective_received_quantity(item): class ProcurementRequestSummaryMixin(serializers.Serializer): + is_viewed = serializers.SerializerMethodField() next_expected_delivery_date = serializers.SerializerMethodField() items_total_count = serializers.SerializerMethodField() items_received_count = serializers.SerializerMethodField() @@ -139,6 +141,23 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): total_received_quantity = serializers.SerializerMethodField() quantity_unit_label = serializers.SerializerMethodField() + @extend_schema_field(serializers.BooleanField()) + def get_is_viewed(self, obj): + annotated_value = getattr(obj, "is_viewed_for_current_user", None) + if annotated_value is not None: + return bool(annotated_value) + + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return False + + return ProcurementRequestView.objects.filter( + request=obj, + user=user, + is_viewed=True, + ).exists() + @extend_schema_field(serializers.DateField(allow_null=True)) def get_next_expected_delivery_date(self, obj): items = _request_items(obj) @@ -525,6 +544,7 @@ class ProcurementRequestListSerializer( "urgency_display", "fulfillment_status", "fulfillment_status_display", + "is_viewed", "total_cost", "items_count", "next_expected_delivery_date", @@ -545,12 +565,14 @@ class ProcurementRequestListSerializer( "created_at", "submitted_at", "started_at", + "completed_at", ] read_only_fields = [ "id", "created_at", "submitted_at", "started_at", + "completed_at", "department_name", "processing_department_name", "requestor_name", @@ -559,6 +581,7 @@ class ProcurementRequestListSerializer( "urgency_display", "fulfillment_status", "fulfillment_status_display", + "is_viewed", "items_count", "total_cost", "next_expected_delivery_date", @@ -761,6 +784,7 @@ class ProcurementRequestDetailSerializer( "urgency_display", "fulfillment_status", "fulfillment_status_display", + "is_viewed", "total_cost", "actual_cost", "next_expected_delivery_date", @@ -795,6 +819,7 @@ class ProcurementRequestDetailSerializer( "started_at", "completed_at", "is_editable", + "is_viewed", "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 23060c99..0b964019 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -24,6 +24,7 @@ from procurement.models import ( MaintenanceRecord, ProcurementItem, ProcurementRequest, + ProcurementRequestView, Supplier, ) from procurement.notifications.handlers import ( @@ -97,6 +98,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "complete", "cancel", "mark_all_received", + "set_viewed", ]: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer @@ -105,7 +107,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Выбрать права доступа в зависимости от действия.""" if self.action in ["approve", "reject"]: permission_classes = [CanApproveProcurementRequest] - elif self.action == "submit": + elif self.action in ["submit", "set_viewed"]: permission_classes = [permissions.IsAuthenticated] else: # Для create, update, delete и остальных действий @@ -116,7 +118,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Фильтровать заявки в зависимости от роли пользователя и scope.""" from datetime import timedelta from django.contrib.contenttypes.models import ContentType - from django.db.models import Count, IntegerField, OuterRef, Subquery, Value + from django.db.models import ( + BooleanField, + Count, + Exists, + IntegerField, + OuterRef, + Subquery, + Value, + ) from django.db.models.functions import Coalesce from django.utils import timezone from communications.models import Chat @@ -136,14 +146,27 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): .values("msg_count")[:1] ) + user = self.request.user + viewed_annotation = ( + Exists( + ProcurementRequestView.objects.filter( + request_id=OuterRef("pk"), + user=user, + is_viewed=True, + ) + ) + if user and user.is_authenticated + else Value(False, output_field=BooleanField()) + ) + queryset = super().get_queryset().annotate( comments_count=Coalesce( Subquery(comments_sub, output_field=IntegerField()), Value(0), output_field=IntegerField(), - ) + ), + is_viewed_for_current_user=viewed_annotation, ) - user = self.request.user scope = self.request.query_params.get("scope", None) period = self.request.query_params.get("period", None) participant_department_ids = ( @@ -788,6 +811,49 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(procurement_request) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def set_viewed(self, request, pk=None): + """Установить отметку просмотра заявки.""" + procurement_request = self.get_object() + raw_value = request.data.get("is_viewed", True) + + if isinstance(raw_value, bool): + is_viewed = raw_value + elif isinstance(raw_value, str): + normalized = raw_value.strip().lower() + if normalized in {"true", "1", "yes", "y", "on"}: + is_viewed = True + elif normalized in {"false", "0", "no", "n", "off"}: + is_viewed = False + else: + return Response( + {"is_viewed": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"is_viewed": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + view_state, _ = ProcurementRequestView.objects.get_or_create( + request=procurement_request, + user=request.user, + defaults={ + "is_viewed": is_viewed, + "viewed_at": timezone.now() if is_viewed else None, + }, + ) + if view_state.is_viewed != is_viewed: + view_state.is_viewed = is_viewed + view_state.viewed_at = timezone.now() if is_viewed else None + view_state.save(update_fields=["is_viewed", "viewed_at", "updated_at"]) + + procurement_request.is_viewed_for_current_user = is_viewed + + serializer = self.get_serializer(procurement_request) + return Response(serializer.data) + @action(detail=True, methods=["post"]) def cancel(self, request, pk=None): """Отменить заявку.""" @@ -1178,20 +1244,33 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): if error_response is not None: return error_response - if (item.received_quantity or 0) <= 0: + if item.effective_received_quantity <= 0: return Response( {"detail": "По позиции нет подтверждённого получения"}, status=status.HTTP_400_BAD_REQUEST, ) + effective_ordered_quantity = item.effective_ordered_quantity item.received_quantity = 0 + if item.ordered_quantity is None and effective_ordered_quantity > 0: + item.ordered_quantity = min(effective_ordered_quantity, item.quantity) item.execution_status = self._workflow_status_from_quantities(item) - item.save(update_fields=["execution_status", "received_quantity"]) + item.save( + update_fields=[ + "execution_status", + "ordered_quantity", + "received_quantity", + ] + ) self._reopen_completed_request(procurement_request, user) notify_item_updated( item, actor=user, - changed_fields=["execution_status", "received_quantity"], + changed_fields=[ + "execution_status", + "ordered_quantity", + "received_quantity", + ], ) item.refresh_from_db() diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index 50c8000f..c99e9d54 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -12,6 +12,7 @@ from .models import ( MaintenanceRecord, ProcurementItem, ProcurementRequest, + ProcurementRequestView, Supplier, ) @@ -52,6 +53,27 @@ class ApprovalInline(admin.TabularInline): can_delete = False +@admin.register(ProcurementRequestView) +class ProcurementRequestViewAdmin(admin.ModelAdmin): + """Админка для персональных отметок просмотра заявок.""" + + list_display = [ + "request", + "user", + "is_viewed", + "viewed_at", + "updated_at", + ] + list_filter = ["is_viewed", "viewed_at", "updated_at"] + search_fields = [ + "request__title", + "user__email", + "user__first_name", + "user__last_name", + ] + readonly_fields = ["updated_at"] + + @admin.register(ProcurementRequest) class ProcurementRequestAdmin(admin.ModelAdmin): """Админка для заявок на закупку.""" diff --git a/backend/procurement/migrations/0013_procurementrequestview.py b/backend/procurement/migrations/0013_procurementrequestview.py new file mode 100644 index 00000000..05de5e63 --- /dev/null +++ b/backend/procurement/migrations/0013_procurementrequestview.py @@ -0,0 +1,75 @@ +# Generated by Codex on 2026-06-08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("procurement", "0012_expected_delivery_dates"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ProcurementRequestView", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_viewed", + models.BooleanField(default=True, verbose_name="Просмотрено"), + ), + ( + "viewed_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Просмотрено в", + ), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="view_states", + to="procurement.procurementrequest", + verbose_name="Заявка", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="procurement_request_views", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Отметка просмотра заявки", + "verbose_name_plural": "Отметки просмотра заявок", + }, + ), + migrations.AddConstraint( + model_name="procurementrequestview", + constraint=models.UniqueConstraint( + fields=("request", "user"), + name="unique_procurement_request_view_user", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 482df843..ffd172a6 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -108,7 +108,6 @@ class ProcurementRequest(models.Model): blank=True, validators=[MinValueValidator(Decimal("0.01"))], ) - # Даты created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -244,6 +243,43 @@ class ProcurementRequest(models.Model): return new_status +class ProcurementRequestView(models.Model): + """Персональная отметка просмотра заявки пользователем.""" + + request = models.ForeignKey( + ProcurementRequest, + on_delete=models.CASCADE, + related_name="view_states", + verbose_name="Заявка", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="procurement_request_views", + verbose_name="Пользователь", + ) + is_viewed = models.BooleanField("Просмотрено", default=True) + viewed_at = models.DateTimeField( + "Просмотрено в", + null=True, + blank=True, + ) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Отметка просмотра заявки" + verbose_name_plural = "Отметки просмотра заявок" + constraints = [ + models.UniqueConstraint( + fields=["request", "user"], + name="unique_procurement_request_view_user", + ), + ] + + def __str__(self): + return f"{self.request_id}: {self.user_id} viewed={self.is_viewed}" + + class ProcurementItem(models.Model): """Позиция в заявке на закупку.""" diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 37863689..3a2d69c4 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -243,6 +243,7 @@ class ProcurementApprovalResolver: if procurement_request.status not in { ProcurementStatus.WAITING, ProcurementStatus.IN_PROGRESS, + ProcurementStatus.COMPLETED, }: return False diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 4a92f85f..722ebb36 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -1037,6 +1037,19 @@ class TestProcessingDepartmentWorkflow: assert role_response.data["can_current_user_start_work"] is True assert role_response.data["can_current_user_process_items"] is True + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.save( + update_fields=["status", "completed_at", "updated_at"], + ) + + completed_role_response = api_client.get(detail_url) + assert completed_role_response.status_code == status.HTTP_200_OK + assert ( + completed_role_response.data["can_current_user_process_items"] + is True + ) + def test_outsider_cannot_start_processing_department_request( self, api_client, outsider, processing_request, procurement_item_factory ): @@ -2153,6 +2166,47 @@ class TestProcessingDepartmentWorkflow: assert processing_request.status == ProcurementStatus.IN_PROGRESS assert processing_request.completed_at is None + def test_cancel_received_uses_received_status_as_effective_quantity( + self, api_client, user, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=2, + ordered_quantity=2, + received_quantity=2, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + ProcurementItem.objects.filter(pk=item.pk).update( + ordered_quantity=None, + received_quantity=None, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.COMPLETED + processing_request.completed_at = timezone.now() + processing_request.fulfillment_status = ( + ProcurementFulfillmentStatus.COMPLETED + ) + processing_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-cancel-received', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url, {}, format='json') + + assert response.status_code == status.HTTP_200_OK + item.refresh_from_db() + processing_request.refresh_from_db() + assert item.execution_status == ProcurementItemExecutionStatus.ORDERED + assert item.ordered_quantity == item.quantity + assert item.received_quantity == 0 + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.completed_at is None + def test_outsider_cannot_confirm_item_received( self, api_client, outsider, processing_request, procurement_item_factory diff --git a/backend/tests/procurement/test_api.py b/backend/tests/procurement/test_api.py index 517eddff..fd54d586 100644 --- a/backend/tests/procurement/test_api.py +++ b/backend/tests/procurement/test_api.py @@ -6,6 +6,7 @@ from decimal import Decimal import pytest from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -17,6 +18,7 @@ from procurement.models import ( EquipmentCategory, ProcurementItem, ProcurementRequest, + ProcurementRequestView, Supplier, ) @@ -98,6 +100,22 @@ class TestProcurementRequestAPI: assert len(response.data['results']) == 1 assert response.data['results'][0]['title'] == "Тестовая заявка" + def test_list_completed_request_includes_completed_at( + self, api_client, user, procurement_request + ): + """Тест: список отдаёт дату завершения для завершённых заявок.""" + procurement_request.status = ProcurementStatus.COMPLETED + procurement_request.completed_at = timezone.now() + procurement_request.save(update_fields=["status", "completed_at"]) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['results'][0]['status'] == ProcurementStatus.COMPLETED + assert response.data['results'][0]['completed_at'] is not None + def test_create_request( self, api_client, user, department, link_factory ): @@ -188,6 +206,35 @@ class TestProcurementRequestAPI: assert response.status_code == status.HTTP_200_OK assert response.data['results'][0]['quantity_unit_label'] == "единиц" + def test_set_request_viewed_flag( + self, api_client, user, procurement_request + ): + """Тест: пользователь может отметить видимую заявку просмотренной.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-set-viewed', + kwargs={'pk': procurement_request.id}, + ) + + response = api_client.post(url, {'is_viewed': True}, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['is_viewed'] is True + view_state = ProcurementRequestView.objects.get( + request=procurement_request, + user=user, + ) + assert view_state.is_viewed is True + assert view_state.viewed_at is not None + + response = api_client.post(url, {'is_viewed': False}, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['is_viewed'] is False + view_state.refresh_from_db() + assert view_state.is_viewed is False + assert view_state.viewed_at is None + def test_update_own_request_draft( self, api_client, user, procurement_request ): diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 0776c62c..1587dce8 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -17,6 +17,8 @@ import { ChevronDown, ChevronRight, ClipboardCheck, + Eye, + EyeOff, Filter, Loader2, MessageSquare, @@ -118,6 +120,15 @@ const fmt = formatDate; const money = formatMoney; type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete"; +const getRequestListDateLabel = (request: ProcurementRequest, status: string) => { + const createdAt = fmt(request.created_at); + if (status === "completed" && request.completed_at) { + const completedAt = fmt(request.completed_at); + return completedAt ? `${createdAt} - ${completedAt}` : createdAt; + } + return createdAt; +}; + const getReadableError = (error: unknown, fallback: string): string => { const raw = String((error as Error)?.message || fallback); const jsonStart = raw.indexOf("{"); @@ -332,6 +343,7 @@ export default function ProcurementPage() { handleCancel, handleComplete, handleMarkAllReceived, + handleToggleViewed, handleReportItemIssue, handleCancelItemIssue, handleConfirmItemReceived, @@ -803,6 +815,12 @@ export default function ProcurementPage() { const comments = commentsMap[req.id] || []; const commentsOpen = Boolean(expandedComments[req.id]); const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length; + const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed); + const rowExpanded = !isViewed && expanded; + const rowCommentsOpen = !isViewed && commentsOpen; + const showInlineRequestActions = !isViewed && hasInlineRequestActions; + const showSecondaryActions = !isViewed && hasSecondaryActions; + const requestMenuOpen = showSecondaryActions && 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); @@ -824,30 +842,48 @@ export default function ProcurementPage() { ); const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null; const approvalsCount = resolvedDetail.approvals?.length ?? 0; + const requestDateLabel = getRequestListDateLabel(resolvedDetail, st); return ( -
-
+
+
-
+
+ {!isViewed ? ( + <> + + + + ) : null} -
@@ -862,23 +898,23 @@ export default function ProcurementPage() { className="truncate text-left text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]" title="Открыть карточку заявки" > - {req.title || "Без названия"} + {req.title || "Без названия"}
Отдел-заказчик: {getDeptName(req)} - {fmt(req.created_at)} + {requestDateLabel} {getRequestAmount(req) && {money(getRequestAmount(req))}}
{sMeta.label} - {hasSecondaryActions ? ( + {showSecondaryActions ? (
- {procurementMenuOpenId === req.id ? ( + {requestMenuOpen ? (
{canCancelThis ? (
- {urg.label} срочность + {!isViewed ? ( + {urg.label} срочность + ) : null}
-
-
- Заявитель:{" "} - {requestorLink - ? {requestorName} - : {requestorName}} -
-
- Исполнитель:{" "} - {req.executor ? ( - executorLink - ? {executorName} - : {executorName} - ) : ( - не назначен - )} -
- {(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && ( -
- {fulfillmentStatusDisplay ? ( - - {fulfillmentStatusDisplay} - - ) : null} - {nextExpectedDeliveryDate ? ( - - {fmt(nextExpectedDeliveryDate)} - - ) : null} - {itemsTotalCount > 0 && ( - - {itemsTotalCount} поз. - - )} - {itemsTotalCount > 0 && ( - - Получено {itemsReceivedCount}/{itemsTotalCount} поз. - - )} - {itemsProblemCount > 0 && ( - - Проблемы {itemsProblemCount}/{itemsTotalCount} поз. - - )} - {totalRequestedQuantity > 0 && ( - - Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} - - )} - {totalRequestedQuantity > 0 && ( - - Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} - - )} - {itemsPendingCount > 0 && ( - - Не обработано {itemsPendingCount} - - )} - {approvalsCount > 0 && ( - - {approvalsCount} соглас. - + {!isViewed ? ( +
+
+ Заявитель:{" "} + {requestorLink + ? {requestorName} + : {requestorName}} +
+
+ Исполнитель:{" "} + {req.executor ? ( + executorLink + ? {executorName} + : {executorName} + ) : ( + не назначен )}
- )} -
+ {(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && ( +
+ {fulfillmentStatusDisplay ? ( + + {fulfillmentStatusDisplay} + + ) : null} + {nextExpectedDeliveryDate ? ( + + {fmt(nextExpectedDeliveryDate)} + + ) : null} + {itemsTotalCount > 0 && ( + + {itemsTotalCount} поз. + + )} + {itemsTotalCount > 0 && ( + + Получено {itemsReceivedCount}/{itemsTotalCount} поз. + + )} + {itemsProblemCount > 0 && ( + + Проблемы {itemsProblemCount}/{itemsTotalCount} поз. + + )} + {totalRequestedQuantity > 0 && ( + + Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} + + )} + {totalRequestedQuantity > 0 && ( + + Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel} + + )} + {itemsPendingCount > 0 && ( + + Не обработано {itemsPendingCount} + + )} + {approvalsCount > 0 && ( + + {approvalsCount} соглас. + + )} +
+ )} +
+ ) : null} - {hasInlineRequestActions ? ( + {showInlineRequestActions ? (
- {(expanded || commentsOpen) && ( + {(rowExpanded || rowCommentsOpen) && (
- {commentsOpen ? ( -
+ {rowCommentsOpen ? ( +
{comments.length === 0 ? (

Комментариев пока нет

@@ -1080,7 +1120,7 @@ export default function ProcurementPage() {
) : null} - {expanded ? ( + {rowExpanded ? ( { return Number.isFinite(parsed) ? parsed : 0; }; +const effectiveOrderedQuantity = (item: ProcurementItem, requestedQuantity: number): number => { + if (item.ordered_quantity !== null && item.ordered_quantity !== undefined) { + return item.ordered_quantity; + } + if (item.execution_status === "ordered" || item.execution_status === "received") { + return requestedQuantity; + } + return 0; +}; + +const effectiveReceivedQuantity = (item: ProcurementItem, requestedQuantity: number): number => { + if (item.received_quantity !== null && item.received_quantity !== undefined) { + return item.received_quantity; + } + if (item.execution_status === "received") { + return requestedQuantity; + } + return 0; +}; + const toOptionalInteger = (value: string): number | null => { if (value.trim() === "") return null; const parsed = Number(value); @@ -181,8 +201,8 @@ function ProcurementItemCard({ const expectedDeliveryDates = toDateRows(item.expected_delivery_dates); const commentsTotal = item.comments_count ?? comments.length; const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity))); - const orderedQuantity = item.ordered_quantity ?? 0; - const receivedQuantity = item.received_quantity ?? 0; + const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity); + const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity); const itemUnitLabel = getProcurementUnitLabel(item.unit); const status = item.execution_status || "pending"; const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено"; @@ -209,8 +229,7 @@ function ProcurementItemCard({ canReportIssue && onReportItemIssue && status !== "rejected" && - status !== "defective" && - (canEditItemProcessing || status === "received") + status !== "defective" ); const updateDraft = (patch: Partial) => { @@ -662,6 +681,7 @@ export function ProcurementRequestDetailContent({ }: ProcurementRequestDetailContentProps) { const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem); const isRequestPending = String(request.status || "").toLowerCase() === "pending"; + const canMarkAllReceived = canEditItemProcessing && ["waiting", "in_progress"].includes(String(request.status || "").toLowerCase()); const displayItems = request.items ? [...request.items].reverse() : []; const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null; const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null; @@ -725,7 +745,7 @@ export function ProcurementRequestDetailContent({

Позиции

- {canEditItemProcessing && onMarkAllReceived ? ( + {canMarkAllReceived && onMarkAllReceived ? (