Fix procurement viewed state and item actions

This commit is contained in:
lizerk 2026-06-08 16:25:19 +03:00
parent d6a7ce530d
commit 4555ee9bf7
13 changed files with 558 additions and 109 deletions

View File

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

View File

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

View File

@ -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):
"""Админка для заявок на закупку."""

View File

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

View File

@ -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):
"""Позиция в заявке на закупку."""

View File

@ -243,6 +243,7 @@ class ProcurementApprovalResolver:
if procurement_request.status not in {
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
ProcurementStatus.COMPLETED,
}:
return False

View File

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

View File

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

View File

@ -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 (
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${procurementMenuOpenId === req.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="px-4 py-3">
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className={isViewed ? "px-4 py-2.5" : "px-4 py-3"}>
<div className="flex items-start gap-3">
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
<div className={`flex shrink-0 flex-col items-center ${isViewed ? "gap-0 pt-0.5" : "gap-3 pt-0.5"}`}>
{!isViewed ? (
<>
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={rowExpanded ? "Свернуть детали" : "Развернуть детали"}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
>
<ChevronDown size={15} className={`transition ${rowExpanded ? "rotate-180" : ""}`} />
</button>
<button
type="button"
title={`Комментарии (${commentsTotal})`}
onClick={() => void toggleComments(req.id)}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
>
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
</button>
</>
) : null}
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
title={isViewed ? "Отметить непросмотренной" : "Отметить просмотренной"}
aria-label={isViewed ? "Отметить заявку непросмотренной" : "Отметить заявку просмотренной"}
disabled={busyKey === `viewed-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
void handleToggleViewed(req.id, !isViewed);
}}
className={`${isViewed ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60`}
>
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
</button>
<button
type="button"
title={`Комментарии (${commentsTotal})`}
onClick={() => void toggleComments(req.id)}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
>
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
{isViewed ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
</div>
@ -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 || "Без названия"}
</button>
</div>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span></span>
<span>{fmt(req.created_at)}</span>
<span>{requestDateLabel}</span>
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
</div>
</div>
<div
ref={procurementMenuOpenId === req.id ? procurementMenuRef : null}
ref={requestMenuOpen ? procurementMenuRef : null}
className="flex shrink-0 flex-col items-end gap-2"
>
<div className="flex items-center gap-2">
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
{hasSecondaryActions ? (
{showSecondaryActions ? (
<div className="relative">
<button
type="button"
@ -886,15 +922,15 @@ export default function ProcurementPage() {
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с заявкой"
aria-label="Действия с заявкой"
aria-expanded={procurementMenuOpenId === req.id}
aria-expanded={requestMenuOpen}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${procurementMenuOpenId === req.id ? "rotate-90" : ""}`}
className={`transition-transform duration-200 ${requestMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{procurementMenuOpenId === req.id ? (
{requestMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canCancelThis ? (
<button
@ -942,79 +978,83 @@ export default function ProcurementPage() {
</div>
) : null}
</div>
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
{!isViewed ? (
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
) : null}
</div>
</div>
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
<span>Заявитель:</span>{" "}
{requestorLink
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
</div>
<div className="min-w-0">
<span>Исполнитель:</span>{" "}
{req.executor ? (
executorLink
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
) : (
<span>не назначен</span>
)}
</div>
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
<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`}>
{fulfillmentStatusDisplay}
</span>
) : null}
{nextExpectedDeliveryDate ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
</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} поз.
</span>
)}
{itemsTotalCount > 0 && (
<span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
</span>
)}
{itemsProblemCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{itemsPendingCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Не обработано {itemsPendingCount}
</span>
)}
{approvalsCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Check size={11} /> {approvalsCount} соглас.
</span>
{!isViewed ? (
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
<span>Заявитель:</span>{" "}
{requestorLink
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
</div>
<div className="min-w-0">
<span>Исполнитель:</span>{" "}
{req.executor ? (
executorLink
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
) : (
<span>не назначен</span>
)}
</div>
)}
</div>
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
<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`}>
{fulfillmentStatusDisplay}
</span>
) : null}
{nextExpectedDeliveryDate ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
</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} поз.
</span>
)}
{itemsTotalCount > 0 && (
<span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
</span>
)}
{itemsProblemCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{itemsPendingCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Не обработано {itemsPendingCount}
</span>
)}
{approvalsCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Check size={11} /> {approvalsCount} соглас.
</span>
)}
</div>
)}
</div>
) : null}
{hasInlineRequestActions ? (
{showInlineRequestActions ? (
<div className="mt-3 flex justify-end">
<ProcurementRequestActionButtons
request={resolvedDetail}
@ -1040,10 +1080,10 @@ export default function ProcurementPage() {
</div>
</div>
{(expanded || commentsOpen) && (
{(rowExpanded || rowCommentsOpen) && (
<div className="mt-4 space-y-3 px-4 pb-4">
{commentsOpen ? (
<div className={expanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
{rowCommentsOpen ? (
<div className={rowExpanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
@ -1080,7 +1120,7 @@ export default function ProcurementPage() {
</div>
) : null}
{expanded ? (
{rowExpanded ? (
<ProcurementRequestDetailContent
currentUserId={user?.id}
request={resolvedDetail}

View File

@ -76,6 +76,26 @@ const toNumber = (value?: string | number | null): number => {
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<ItemProcessingDraft>) => {
@ -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({
<div className="app-surface rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
{canEditItemProcessing && onMarkAllReceived ? (
{canMarkAllReceived && onMarkAllReceived ? (
<button
type="button"
onClick={() => onMarkAllReceived(request.id)}

View File

@ -527,6 +527,21 @@ export function useProcurementPage(user: User | null) {
await refreshCurrentView();
}, [refreshCurrentView, refreshOne]);
const applyRequestStateUpdate = useCallback((updated: ProcurementRequest) => {
setRequests((previous) => previous.map((request) => (
request.id === updated.id ? { ...request, ...updated } : request
)));
setDetailsCache((previous) => {
const cached = previous[updated.id] || detailsCacheRef.current[updated.id];
const next = {
...previous,
[updated.id]: cached ? { ...cached, ...updated } : updated,
};
detailsCacheRef.current = next;
return next;
});
}, []);
const ensureRequestDetail = useCallback(async (id: number) => {
const cached = detailsCacheRef.current[id];
if (cached) {
@ -900,6 +915,37 @@ export function useProcurementPage(user: User | null) {
"Все позиции отмечены полученными.",
), [doAction]);
const handleToggleViewed = useCallback(async (id: number, isViewed: boolean) => {
try {
setBusyKey(`viewed-${id}`);
setActionError(null);
const updated = await apiClient.setProcurementRequestViewed(id, isViewed) as ProcurementRequest;
applyRequestStateUpdate(updated);
if (isViewed) {
setExpandedIds((previous) => {
if (!previous.has(id)) {
return previous;
}
const next = new Set(previous);
next.delete(id);
return next;
});
setExpandedComments((previous) => {
if (!previous[id]) {
return previous;
}
return { ...previous, [id]: false };
});
}
return true;
} catch (viewedError) {
setActionError(getReadableError(viewedError, "Не удалось обновить отметку просмотра"));
return false;
} finally {
setBusyKey(null);
}
}, [applyRequestStateUpdate]);
const handleUpdateItem = useCallback(async (requestId: number, itemId: number, patch: Record<string, unknown>) => {
try {
setBusyKey(`item-${itemId}`);
@ -1102,6 +1148,7 @@ export function useProcurementPage(user: User | null) {
handleStart,
handleComplete,
handleMarkAllReceived,
handleToggleViewed,
handleUpdateItem,
handleReportItemIssue,
handleCancelItemIssue,

View File

@ -16,6 +16,8 @@ export function createProcurementApi(request: RequestFn) {
startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }),
completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }),
markAllReceivedProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/mark_all_received/`, { method: 'POST' }),
setProcurementRequestViewed: (id: number, isViewed: boolean) =>
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
cancelProcurementRequest: (id: number, reason?: string) =>
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),

View File

@ -591,6 +591,7 @@ export interface ProcurementRequest {
required_approval_priorities?: number[];
actual_cost?: string | number | null;
is_editable?: boolean;
is_viewed?: boolean;
total_cost?: string | number;
total_estimated_cost?: string | number;
items_count?: number;