Add subscription and project-aware procurement workflows

This commit is contained in:
lizerk 2026-10-25 15:35:46 +03:00
parent 5ded148251
commit 18d170ffd2
23 changed files with 994 additions and 82 deletions

View File

@ -17,6 +17,10 @@ class ProcurementRequestCreateOptionsSerializer(serializers.Serializer):
processing_departments = DepartmentBriefSerializer(many=True)
default_processing_department = serializers.IntegerField(allow_null=True)
subscription_processing_departments = DepartmentBriefSerializer(many=True)
subscription_default_processing_department = serializers.IntegerField(
allow_null=True
)
class ProcurementManualApprovalStepSerializer(serializers.Serializer):

View File

@ -6,7 +6,7 @@ from datetime import date
from rest_framework import serializers
from procurement.constants import ProcurementItemExecutionStatus
from procurement.models import ProcurementSettings
from procurement.models import ProcurementRequest, ProcurementSettings
PROBLEM_ITEM_STATUSES = {
ProcurementItemExecutionStatus.REJECTED,
@ -85,23 +85,27 @@ def _expected_delivery_date_values(item):
return dates
def _validate_processing_department_available(processing_department):
def _validate_processing_department_available(
processing_department,
request_type=ProcurementRequest.RequestType.STANDARD,
):
if not processing_department:
return
settings = ProcurementSettings.get_solo()
available_departments = settings.available_processing_departments.all()
if request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
available_departments = (
settings.subscription_available_processing_departments.all()
)
error_message = "Выберите отдел-исполнитель из допустимых для подписок."
else:
available_departments = settings.available_processing_departments.all()
error_message = "Выберите отдел-исполнитель из доступных для закупок."
if (
available_departments.exists()
and not available_departments.filter(pk=processing_department.pk).exists()
):
raise serializers.ValidationError(
{
"processing_department": (
"Выберите отдел-исполнитель из доступных для закупок."
)
}
)
raise serializers.ValidationError({"processing_department": error_message})
def _request_items(obj):

View File

@ -20,6 +20,8 @@ from .common import (
class ProcurementRequestSummaryMixin(serializers.Serializer):
is_viewed = serializers.SerializerMethodField()
is_deferred = serializers.SerializerMethodField()
project_names = serializers.SerializerMethodField()
last_arrival_notice_at = serializers.DateTimeField(
read_only=True,
allow_null=True,
@ -56,6 +58,27 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
is_viewed=True,
).exists()
@extend_schema_field(serializers.BooleanField())
def get_is_deferred(self, obj):
annotated_value = getattr(obj, "is_deferred_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_deferred=True,
).exists()
@extend_schema_field(serializers.ListField(child=serializers.CharField()))
def get_project_names(self, obj):
return [project.name for project in obj.projects.all()]
@extend_schema_field(serializers.DateField(allow_null=True))
def get_next_expected_delivery_date(self, obj):
items = _request_items(obj)

View File

@ -8,6 +8,8 @@ from procurement.application import (
update_procurement_request_with_items,
)
from procurement.models import ProcurementRequest
from projects.models import Project
from projects.permissions import visible_projects
from .approvals import ApprovalSerializer
from .common import (
@ -58,11 +60,27 @@ class ProcurementRequestListSerializer(
source="executor.get_full_name", read_only=True, allow_null=True
)
comments_count = serializers.IntegerField(read_only=True, default=0)
projects = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Project.objects.none(),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request and request.user.is_authenticated:
self.fields["projects"].child_relation.queryset = visible_projects(
request.user
)
class Meta:
model = ProcurementRequest
fields = [
"id",
"request_type",
"projects",
"project_names",
"title",
"description",
"department",
@ -80,6 +98,7 @@ class ProcurementRequestListSerializer(
"fulfillment_status",
"fulfillment_status_display",
"is_viewed",
"is_deferred",
"last_arrival_notice_at",
"last_defective_at",
"total_cost",
@ -107,6 +126,7 @@ class ProcurementRequestListSerializer(
]
read_only_fields = [
"id",
"request_type",
"created_at",
"submitted_at",
"started_at",
@ -176,7 +196,8 @@ class ProcurementRequestListSerializer(
attrs = super().validate(attrs)
if "processing_department" in attrs:
_validate_processing_department_available(
attrs.get("processing_department")
attrs.get("processing_department"),
request_type=self.instance.request_type,
)
items_data = attrs.get("items")
if items_data is None:
@ -232,6 +253,7 @@ class ProcurementRequestDetailSerializer(
max_digits=12, decimal_places=2, read_only=True
)
items = ProcurementItemSerializer(many=True, read_only=True)
projects = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
approvals = ApprovalSerializer(many=True, read_only=True)
required_approval_priorities = serializers.ListField(
source="get_required_approval_priorities", read_only=True
@ -248,6 +270,9 @@ class ProcurementRequestDetailSerializer(
model = ProcurementRequest
fields = [
"id",
"request_type",
"projects",
"project_names",
"title",
"description",
"department",
@ -266,6 +291,7 @@ class ProcurementRequestDetailSerializer(
"fulfillment_status",
"fulfillment_status_display",
"is_viewed",
"is_deferred",
"last_arrival_notice_at",
"last_defective_at",
"total_cost",

View File

@ -5,7 +5,9 @@ from rest_framework import serializers
from procurement.application import (
create_procurement_request,
)
from procurement.models import ProcurementRequest
from procurement.models import ProcurementRequest, ProcurementSettings
from projects.models import Project
from projects.permissions import visible_projects
from .common import (
_validate_processing_department_available,
@ -23,12 +25,27 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
allow_blank=True,
)
items = ProcurementItemCreateSerializer(many=True, required=False)
projects = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Project.objects.none(),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request and request.user.is_authenticated:
self.fields["projects"].child_relation.queryset = visible_projects(
request.user
)
class Meta:
model = ProcurementRequest
fields = [
"id",
"title",
"request_type",
"projects",
"description",
"department",
"processing_department",
@ -41,7 +58,23 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "requestor", "status", "created_at"]
def validate(self, attrs):
_validate_processing_department_available(attrs.get("processing_department"))
_validate_processing_department_available(
attrs.get("processing_department"),
request_type=attrs.get(
"request_type", ProcurementRequest.RequestType.STANDARD
),
)
if (
attrs.get("request_type") == ProcurementRequest.RequestType.SUBSCRIPTION
and not ProcurementSettings.get_solo().subscription_approver_id
):
raise serializers.ValidationError(
{
"request_type": (
"В настройках закупок не выбран согласующий заявок на подписки."
)
}
)
if (
not attrs.get("processing_department")
and not attrs.get("description", "").strip()
@ -54,9 +87,12 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Создать заявку с позициями."""
items_data = validated_data.pop("items", [])
projects = validated_data.pop("projects", [])
request = self.context["request"]
return create_procurement_request(
procurement_request = create_procurement_request(
requestor=request.user,
attributes=validated_data,
items=items_data,
)
procurement_request.projects.set(projects)
return procurement_request

View File

@ -400,6 +400,30 @@ class RequestExecutionMixin:
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def set_deferred(self, request, pk=None):
"""Установить личную отметку «отложено» для заявки."""
procurement_request = self.get_object()
raw_value = request.data.get("is_deferred")
if not isinstance(raw_value, bool):
return Response(
{"is_deferred": "Ожидается булево значение."},
status=status.HTTP_400_BAD_REQUEST,
)
view_state, _ = ProcurementRequestView.objects.get_or_create(
request=procurement_request,
user=request.user,
defaults={"is_viewed": False, "is_deferred": raw_value},
)
if view_state.is_deferred != raw_value:
view_state.is_deferred = raw_value
view_state.save(update_fields=["is_deferred", "updated_at"])
procurement_request.is_deferred_for_current_user = raw_value
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@extend_schema(
responses=ProcurementNotificationReadResponseSerializer,
summary="Отметить уведомления по заявке как прочитанные",

View File

@ -103,6 +103,17 @@ class ProcurementRequestQueryMixin:
output_field=IntegerField(),
),
is_viewed_for_current_user=viewed,
is_deferred_for_current_user=(
Exists(
ProcurementRequestView.objects.filter(
request_id=OuterRef("pk"),
user=user,
is_deferred=True,
)
)
if user and user.is_authenticated
else Value(False, output_field=BooleanField())
),
last_arrival_notice_at=Subquery(
self._arrival_notice_subquery(content_type),
output_field=DateTimeField(),
@ -157,6 +168,14 @@ class ProcurementRequestQueryMixin:
)
def _apply_scope(self, queryset, user, scope, department_ids):
if scope == "subscriptions":
queryset = queryset.filter(
request_type=ProcurementRequest.RequestType.SUBSCRIPTION,
)
if self._can_view_all(user):
return queryset
return self._default_visible_queryset(queryset, user, department_ids)
filters_by_scope = {
"mine": {"requestor": user},
"department": {"department_id__in": department_ids},
@ -204,10 +223,22 @@ class ProcurementRequestQueryMixin:
department_ids = (
ProcurementApprovalResolver.get_user_department_participant_ids(user)
)
scope = query_params.get("scope")
base_queryset = self._annotated_queryset(user)
if scope != "subscriptions" and self.action in {
"list",
"pending_approvals",
"my_requests",
"my_work",
"available",
}:
base_queryset = base_queryset.exclude(
request_type=ProcurementRequest.RequestType.SUBSCRIPTION,
)
queryset = self._apply_scope(
self._annotated_queryset(user),
base_queryset,
user,
query_params.get("scope"),
scope,
department_ids,
)
queryset = self._apply_period(queryset, query_params.get("period"))
@ -221,6 +252,11 @@ class ProcurementRequestQueryMixin:
query_params.get("date_to"),
"created_at__date__lte",
)
project_id = query_params.get("project")
if project_id and project_id.isdigit():
queryset = queryset.filter(projects__id=int(project_id))
if query_params.get("deferred") in {"true", "1", "yes"}:
queryset = queryset.filter(is_deferred_for_current_user=True)
if not query_params.get("ordering"):
queryset = queryset.order_by("-default_sort_at", "-id")
return queryset.distinct()

View File

@ -14,6 +14,7 @@ from procurement.constants import (
from procurement.models import (
Approval,
ApprovalRoute,
ProcurementRequest,
ProcurementSettings,
)
from procurement.services import ProcurementApprovalResolver
@ -37,11 +38,28 @@ class RequestWorkflowMixin:
and not departments.filter(pk=default_department_id).exists()
):
default_department_id = None
subscription_departments = (
settings.get_subscription_processing_departments_queryset().order_by("name")
)
subscription_default_department_id = (
settings.subscription_default_processing_department_id
)
if (
subscription_default_department_id
and not subscription_departments.filter(
pk=subscription_default_department_id
).exists()
):
subscription_default_department_id = None
serializer = ProcurementRequestCreateOptionsSerializer(
{
"processing_departments": departments,
"default_processing_department": default_department_id,
"subscription_processing_departments": subscription_departments,
"subscription_default_processing_department": (
subscription_default_department_id
),
}
)
return Response(serializer.data)
@ -364,7 +382,11 @@ class RequestWorkflowMixin:
).count()
if pending_approvals == 0:
if procurement_request.processing_department_id:
if (
procurement_request.processing_department_id
and procurement_request.request_type
!= ProcurementRequest.RequestType.SUBSCRIPTION
):
procurement_request.status = (
ProcurementStatus.IN_PROGRESS
if procurement_request.executor_id

View File

@ -45,6 +45,7 @@ class ProcurementRequestViewSet(
"requestor",
"executor",
).prefetch_related(
"projects",
"items__attachments__uploaded_by",
"approvals__approver",
)
@ -55,6 +56,7 @@ class ProcurementRequestViewSet(
]
filterset_fields = {
"status": ["exact", "in"],
"request_type": ["exact"],
"urgency": ["exact"],
"department": ["exact"],
"processing_department": ["exact"],
@ -81,6 +83,7 @@ class ProcurementRequestViewSet(
"mark_all_received",
"notify_arrival",
"set_viewed",
"set_deferred",
"approval_options",
"activity",
}:
@ -132,6 +135,7 @@ class ProcurementRequestViewSet(
elif self.action in {
"submit",
"set_viewed",
"set_deferred",
"create_options",
"approval_options",
}:

View File

@ -85,6 +85,12 @@ class ProcurementSettingsAdminForm(forms.ModelForm):
cleaned_data = super().clean()
default_department = cleaned_data.get("default_processing_department")
available_departments = cleaned_data.get("available_processing_departments")
subscription_default_department = cleaned_data.get(
"subscription_default_processing_department"
)
subscription_available_departments = cleaned_data.get(
"subscription_available_processing_departments"
)
if (
default_department
@ -100,6 +106,21 @@ class ProcurementSettingsAdminForm(forms.ModelForm):
),
)
if (
subscription_default_department
and subscription_available_departments is not None
and subscription_available_departments.exists()
and subscription_default_department
not in subscription_available_departments
):
self.add_error(
"subscription_default_processing_department",
(
"Отдел по умолчанию для подписок должен входить в список "
"допустимых отделов-исполнителей для подписок."
),
)
return cleaned_data
@ -110,18 +131,26 @@ class ProcurementSettingsAdmin(admin.ModelAdmin):
form = ProcurementSettingsAdminForm
list_display = [
"name",
"subscription_approver",
"default_processing_department",
"subscription_default_processing_department",
"available_processing_departments_count",
"updated_at",
]
fields = [
"name",
"subscription_approver",
"default_processing_department",
"available_processing_departments",
"subscription_default_processing_department",
"subscription_available_processing_departments",
"updated_at",
]
readonly_fields = ["updated_at"]
filter_horizontal = ["available_processing_departments"]
filter_horizontal = [
"available_processing_departments",
"subscription_available_processing_departments",
]
def has_add_permission(self, request):
if ProcurementSettings.objects.exists():
@ -144,6 +173,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
list_display = [
"id",
"request_type",
"title",
"department",
"processing_department",
@ -155,6 +185,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"created_at",
]
list_filter = [
"request_type",
"status",
"fulfillment_status",
"urgency",
@ -178,6 +209,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
"Основная информация",
{
"fields": (
"request_type",
"title",
"description",
"department",

View File

@ -3,11 +3,18 @@
from collections.abc import Mapping, Sequence
from typing import Any
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from communications import comments_helpers
from procurement.constants import ProcurementStatus
from procurement.models import ProcurementItem, ProcurementRequest
from procurement.constants import ApprovalStatus, ProcurementStatus
from procurement.models import (
Approval,
ProcurementItem,
ProcurementRequest,
ProcurementSettings,
)
class ProcurementRequestUpdateError(Exception):
@ -44,6 +51,8 @@ def create_procurement_request(
**request_attributes,
)
procurement_request._notification_actor = requestor
if procurement_request.request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
procurement_request._suppress_created_procurement_notifications = True
procurement_request.save()
for raw_item in items:
@ -59,6 +68,25 @@ def create_procurement_request(
content=initial_comment,
)
if procurement_request.request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
approver_id = ProcurementSettings.get_solo().subscription_approver_id
if not approver_id:
raise ValidationError(
"В настройках закупок не выбран согласующий заявок на подписки."
)
Approval.objects.create(
request=procurement_request,
approver_id=approver_id,
priority=0,
step_name="Согласование подписки",
status=ApprovalStatus.PENDING,
)
procurement_request.status = ProcurementStatus.PENDING
procurement_request.submitted_at = timezone.now()
procurement_request.save(
update_fields=["status", "submitted_at", "updated_at"],
)
return procurement_request
@ -74,6 +102,7 @@ def update_procurement_request_with_items(
procurement_request = ProcurementRequest.objects.select_for_update().get(
pk=request_id,
)
projects = attributes.pop("projects", None)
existing_items = {
item.id: item for item in procurement_request.items.select_for_update().all()
}
@ -81,6 +110,8 @@ def update_procurement_request_with_items(
for field, value in attributes.items():
setattr(procurement_request, field, value)
procurement_request.save()
if projects is not None:
procurement_request.projects.set(projects)
kept_item_ids: set[int] = set()
for raw_item in items:

View File

@ -0,0 +1,37 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("procurement", "0019_procurementitemattachment_thumbnail"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="procurementrequest",
name="request_type",
field=models.CharField(
choices=[("standard", "Обычная закупка"), ("subscription", "Подписка")],
db_index=True,
default="standard",
max_length=20,
verbose_name="Тип заявки",
),
),
migrations.AddField(
model_name="procurementsettings",
name="subscription_approver",
field=models.ForeignKey(
blank=True,
help_text="Новые заявки на подписки сразу попадут этому сотруднику на согласование.",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="subscription_procurement_settings",
to=settings.AUTH_USER_MODEL,
verbose_name="Согласующий заявки на подписки",
),
),
]

View File

@ -0,0 +1,39 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("employees", "0062_employee_additional_positions"),
("procurement", "0020_subscription_requests"),
]
operations = [
migrations.AddField(
model_name="procurementsettings",
name="subscription_default_processing_department",
field=models.ForeignKey(
blank=True,
help_text="Автоматически выбирается в новой заявке на подписку.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="default_subscription_procurement_processing_settings",
to="employees.department",
verbose_name="Отдел-исполнитель подписок по умолчанию",
),
),
migrations.AddField(
model_name="procurementsettings",
name="subscription_available_processing_departments",
field=models.ManyToManyField(
blank=True,
help_text=(
"Если список пуст, для подписок доступны все отделы. "
"Заполните список, чтобы ограничить выбор отдельно от закупок."
),
related_name="subscription_procurement_processing_settings",
to="employees.department",
verbose_name="Допустимые отделы-исполнители для подписок",
),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2026-10-25 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("procurement", "0021_subscription_processing_departments"),
("projects", "0002_projectboardlink"),
]
operations = [
migrations.AddField(
model_name="procurementrequest",
name="projects",
field=models.ManyToManyField(
blank=True,
related_name="procurement_requests",
to="projects.project",
verbose_name="Проекты",
),
),
migrations.AddField(
model_name="procurementrequestview",
name="is_deferred",
field=models.BooleanField(default=False, verbose_name="Отложено для себя"),
),
]

View File

@ -62,6 +62,34 @@ class ProcurementSettings(models.Model):
verbose_name="Отдел-исполнитель по умолчанию",
help_text="Этот отдел будет автоматически выбран в новой заявке.",
)
subscription_available_processing_departments = models.ManyToManyField(
Department,
blank=True,
related_name="subscription_procurement_processing_settings",
verbose_name="Допустимые отделы-исполнители для подписок",
help_text=(
"Если список пуст, для подписок доступны все отделы. "
"Заполните список, чтобы ограничить выбор отдельно от закупок."
),
)
subscription_default_processing_department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="default_subscription_procurement_processing_settings",
verbose_name="Отдел-исполнитель подписок по умолчанию",
help_text="Автоматически выбирается в новой заявке на подписку.",
)
subscription_approver = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="subscription_procurement_settings",
verbose_name="Согласующий заявки на подписки",
help_text="Новые заявки на подписки сразу попадут этому сотруднику на согласование.",
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
@ -96,6 +124,22 @@ class ProcurementSettings(models.Model):
)
}
)
if (
self.pk
and self.subscription_default_processing_department_id
and self.subscription_available_processing_departments.exists()
and not self.subscription_available_processing_departments.filter(
pk=self.subscription_default_processing_department_id
).exists()
):
raise ValidationError(
{
"subscription_default_processing_department": (
"Отдел по умолчанию для подписок должен входить в "
"список допустимых отделов-исполнителей для подписок."
)
}
)
def get_processing_departments_queryset(self):
departments = self.available_processing_departments.all()
@ -103,6 +147,12 @@ class ProcurementSettings(models.Model):
return departments
return Department.objects.all()
def get_subscription_processing_departments_queryset(self):
departments = self.subscription_available_processing_departments.all()
if departments.exists():
return departments
return Department.objects.all()
class ProcurementRequest(models.Model):
"""Заявка на закупку.
@ -114,6 +164,24 @@ class ProcurementRequest(models.Model):
title = models.CharField(
"Название", max_length=255, help_text="Краткое название заявки"
)
class RequestType(models.TextChoices):
STANDARD = "standard", "Обычная закупка"
SUBSCRIPTION = "subscription", "Подписка"
request_type = models.CharField(
"Тип заявки",
max_length=20,
choices=RequestType.choices,
default=RequestType.STANDARD,
db_index=True,
)
projects = models.ManyToManyField(
"projects.Project",
blank=True,
related_name="procurement_requests",
verbose_name="Проекты",
)
description = models.TextField(
"Описание",
help_text="Подробное описание и обоснование необходимости закупки",
@ -384,7 +452,7 @@ class ProcurementRequestActivity(models.Model):
class ProcurementRequestView(models.Model):
"""Персональная отметка просмотра заявки пользователем."""
"""Персональные отметки просмотра и отложенной обработки заявки."""
request = models.ForeignKey(
ProcurementRequest,
@ -399,6 +467,7 @@ class ProcurementRequestView(models.Model):
verbose_name="Пользователь",
)
is_viewed = models.BooleanField("Просмотрено", default=True)
is_deferred = models.BooleanField("Отложено для себя", default=False)
viewed_at = models.DateTimeField(
"Просмотрено в",
null=True,

View File

@ -339,6 +339,13 @@ class ProcurementApprovalResolver:
if not user or not user.is_authenticated:
return False
if (
procurement_request.request_type == "subscription"
and procurement_request.status
not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS}
):
return False
if procurement_request.status not in cls.WORK_ASSIGNABLE_STATUSES:
return False
@ -378,6 +385,13 @@ class ProcurementApprovalResolver:
if not user or not user.is_authenticated:
return False
if (
procurement_request.request_type == "subscription"
and procurement_request.status
not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS}
):
return False
if procurement_request.status not in {
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,

View File

@ -18,6 +18,7 @@ import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
import { useUser } from "@/contexts/UserContext";
import { useRegistrationAvailability } from "@/hooks/useRegistrationAvailability";
import { apiClient } from "@/lib/api";
import { ApiError } from "@/lib/api/client";
import type { QrLoginRequestStatus } from "@/types/api";
type LoginMode = "password" | "qr";
@ -72,9 +73,19 @@ export default function Login() {
// login, even when this was a regular login without an OIDC return URL.
// Otherwise switching portal accounts can leave the previous user in the
// Django session and the next external login silently reuses that user.
const redirectUrl = await apiClient.establishBrowserSession(
nextUrl ?? "",
);
let redirectUrl: string;
try {
redirectUrl = await apiClient.establishBrowserSession(nextUrl ?? "");
} catch (caught) {
// Local and standalone deployments can run without the OIDC provider.
// A regular portal login should still continue when that optional
// browser-session endpoint is disabled; an OIDC return URL must fail.
if (!nextUrl && caught instanceof ApiError && caught.status === 404) {
router.push("/");
return;
}
throw caught;
}
if (!nextUrl) {
router.push("/");
return;

View File

@ -37,6 +37,7 @@ import {
ClipboardCheck,
Eye,
EyeOff,
Flag,
Filter,
Link2,
Loader2,
@ -61,7 +62,10 @@ import {
CommentComposer,
CommentDeleteButton,
} from "@/components/shared/CommentControls";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import {
SearchableSelectMulti,
SearchableSelectSingle,
} from "@/components/shared/SearchableSelect";
import { apiClient } from "@/lib/api";
import { formatDate, formatDateTime, formatMoney } from "@/lib/shared";
import { useProcurementPage } from "@/hooks/useProcurementPage";
@ -183,7 +187,8 @@ type ScopeTab =
| "processing_department"
| "pending_approvals"
| "my_work"
| "available";
| "available"
| "subscriptions";
const scopeTabs: { value: ScopeTab; label: string }[] = [
{ value: "all", label: "Все" },
{ value: "mine", label: "Мои" },
@ -521,11 +526,21 @@ export default function ProcurementPage() {
busyKey,
canManage,
canSupplierManage,
canCreateProjects,
creatingProject,
deferredOnly,
setDeferredOnly,
handleCreateProject,
handleToggleDeferred,
closeModal,
departmentFilter,
departments,
employees,
projects,
newProjectName,
setNewProjectName,
processingDepartments,
subscriptionProcessingDepartments,
detailsCache,
displayUserName,
ensureRequestDetail,
@ -575,6 +590,7 @@ export default function ProcurementPage() {
openEdit,
ordering,
periodFilter,
projectFilter,
processingDepartmentFilter,
requestorFilter,
executorFilter,
@ -594,6 +610,7 @@ export default function ProcurementPage() {
setForm,
setOrdering,
setPeriodFilter,
setProjectFilter,
setDateFromFilter,
setDateToFilter,
setScope,
@ -621,8 +638,14 @@ export default function ProcurementPage() {
markRequestNotificationsRead,
toggleItemComments,
} = useProcurementPage(user);
const configuredProcessingDepartments =
form.request_type === "subscription"
? subscriptionProcessingDepartments
: processingDepartments;
const processingDepartmentOptions =
processingDepartments.length > 0 ? processingDepartments : departments;
configuredProcessingDepartments.length > 0
? configuredProcessingDepartments
: departments;
const userDepartmentIds = useMemo(
() =>
new Set(
@ -1120,9 +1143,17 @@ export default function ProcurementPage() {
</button>
{activeSection === "requests" && (
<ModulePrimaryAction
onClick={openCreate}
onClick={() =>
openCreate(
scope === "subscriptions" ? "subscription" : "standard",
)
}
icon={<Plus size={14} />}
label="Создать заявку"
label={
scope === "subscriptions"
? "Новая подписка"
: "Создать заявку"
}
/>
)}
</div>
@ -1169,31 +1200,55 @@ export default function ProcurementPage() {
</div>
<HorizontalChipScroller
className="mb-4"
ariaLabel="Разделы закупок"
className="mb-3"
ariaLabel="Тип заявок закупок"
>
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value ? "app-pill-active" : "app-pill"
}`}
>
<span>{tab.label}</span>
<span
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value
? "app-pill-count-active"
: "app-pill-count"
<button
type="button"
onClick={() => setScope("all")}
aria-pressed={scope !== "subscriptions"}
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${scope !== "subscriptions" ? "app-pill-active" : "app-pill"}`}
>
Закупки
</button>
<button
type="button"
onClick={() => setScope("subscriptions")}
aria-pressed={scope === "subscriptions"}
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${scope === "subscriptions" ? "app-pill-active" : "app-pill"}`}
>
Подписки
</button>
</HorizontalChipScroller>
{scope === "subscriptions" ? null : (
<HorizontalChipScroller
className="mb-4"
ariaLabel="Фильтры заявок на закупку"
>
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value ? "app-pill-active" : "app-pill"
}`}
>
{scopeCounts[tab.value] ?? 0}
</span>
</button>
))}
</HorizontalChipScroller>
<span>{tab.label}</span>
<span
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value
? "app-pill-count-active"
: "app-pill-count"
}`}
>
{scopeCounts[tab.value] ?? 0}
</span>
</button>
))}
</HorizontalChipScroller>
)}
</>
) : null}
</AutoRevealStickyHeader>
@ -1395,6 +1450,24 @@ export default function ProcurementPage() {
/>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">
Проект
</span>
<ProjectSelectMenu
value={projectFilter}
onChange={setProjectFilter}
ariaLabel="Проект"
options={[
{ value: "", label: "Все проекты" },
...projects.map((project) => ({
value: String(project.id),
label: project.name,
})),
]}
/>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">
Быстрый период
@ -1443,6 +1516,18 @@ export default function ProcurementPage() {
/>
</label>
</div>
<label className="app-surface-muted flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm">
<input
type="checkbox"
checked={deferredOnly}
onChange={(event) =>
setDeferredOnly(event.target.checked)
}
className="h-4 w-4 rounded accent-[var(--accent-primary)]"
/>
<Flag size={14} className="text-amber-500" />
Отложенные мной
</label>
{activeFilterCount > 0 && (
<button
type="button"
@ -1455,6 +1540,8 @@ export default function ProcurementPage() {
setRequestorFilter("");
setExecutorFilter("");
setPeriodFilter("");
setProjectFilter("");
setDeferredOnly(false);
setDateFromFilter("");
setDateToFilter("");
}}
@ -1472,10 +1559,16 @@ export default function ProcurementPage() {
>
<aside
className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]"
aria-label="Список заявок на закупку"
aria-label={
scope === "subscriptions"
? "Список заявок на подписку"
: "Список заявок на закупку"
}
>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
<span className="app-card-caption">Заявки</span>
<span className="app-card-caption">
{scope === "subscriptions" ? "Подписки" : "Заявки"}
</span>
<span className="app-counter inline-flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">
{filteredRequests.length}
</span>
@ -1488,7 +1581,9 @@ export default function ProcurementPage() {
className="app-text-muted mx-auto mb-2"
/>
<p className="app-text-muted text-sm">
Заявок на закупку не найдено
{scope === "subscriptions"
? "Заявок на подписку не найдено"
: "Заявок на закупку не найдено"}
</p>
</div>
) : (
@ -1618,6 +1713,11 @@ export default function ProcurementPage() {
{getDeptName(req)}
</span>
</span>
{(req.project_names || []).length > 0 ? (
<span className="app-text-muted mt-1 block truncate text-[11px]">
Проект: {req.project_names?.join(", ")}
</span>
) : null}
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span
@ -1719,6 +1819,32 @@ export default function ProcurementPage() {
</>
) : null}
</button>
<button
type="button"
title={
req.is_deferred
? "Снять личную отметку «отложено»"
: "Отложить для себя"
}
aria-label={
req.is_deferred
? "Снять отметку «отложено»"
: "Отложить заявку для себя"
}
disabled={busyKey === `deferred-${req.id}`}
onClick={() =>
void handleToggleDeferred(
req.id,
!req.is_deferred,
)
}
className={`${req.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} absolute bottom-2 right-11 z-[1] inline-flex h-8 w-8 items-center justify-center rounded-lg shadow-sm disabled:opacity-60`}
>
<Flag
size={15}
fill={req.is_deferred ? "currentColor" : "none"}
/>
</button>
<button
type="button"
title={
@ -1781,11 +1907,51 @@ export default function ProcurementPage() {
#{selectedRequest.id} ·{" "}
{selectedRequest.title || "Без названия"}
</h2>
<button
type="button"
title={
selectedRequest.is_deferred
? "Снять личную отметку «отложено»"
: "Отложить для себя"
}
aria-label={
selectedRequest.is_deferred
? "Снять отметку «отложено»"
: "Отложить заявку для себя"
}
disabled={
busyKey === `deferred-${selectedRequest.id}`
}
onClick={() =>
void handleToggleDeferred(
selectedRequest.id,
!selectedRequest.is_deferred,
)
}
className={`${selectedRequest.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60`}
>
<Flag
size={15}
fill={
selectedRequest.is_deferred
? "currentColor"
: "none"
}
/>
</button>
<span
className={`app-status-pill shrink-0 ${getStatusMeta(selectedRequest.status).cls}`}
>
{getStatusMeta(selectedRequest.status).label}
</span>
{(selectedRequest.project_names || []).map((name) => (
<span
key={name}
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
>
{name}
</span>
))}
<span
className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}
>
@ -2066,7 +2232,9 @@ export default function ProcurementPage() {
className="app-text-muted mx-auto mb-2"
/>
<p className="app-text-muted text-sm">
Заявок на закупку не найдено
{scope === "subscriptions"
? "Заявок на подписку не найдено"
: "Заявок на закупку не найдено"}
</p>
</div>
) : (
@ -2338,6 +2506,14 @@ export default function ProcurementPage() {
</span>
</span>
<span>{requestDateLabel}</span>
{(req.project_names || []).map((name) => (
<span
key={name}
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
>
{name}
</span>
))}
{getRequestAmount(req) && (
<span className="font-medium text-[var(--foreground)]">
{money(getRequestAmount(req))}
@ -2374,6 +2550,38 @@ export default function ProcurementPage() {
>
{sMeta.label}
</span>
<button
type="button"
title={
req.is_deferred
? "Снять личную отметку «отложено»"
: "Отложить для себя"
}
aria-label={
req.is_deferred
? "Снять отметку «отложено»"
: "Отложить заявку для себя"
}
disabled={
busyKey === `deferred-${req.id}`
}
onClick={() =>
void handleToggleDeferred(
req.id,
!req.is_deferred,
)
}
className={`${req.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60`}
>
<Flag
size={15}
fill={
req.is_deferred
? "currentColor"
: "none"
}
/>
</button>
{showRequestMenu ? (
<div className="relative">
<button
@ -3440,7 +3648,9 @@ export default function ProcurementPage() {
onClose={closeModal}
title={
modalMode === "create"
? "Новая заявка на закупку"
? form.request_type === "subscription"
? "Новая заявка на подписку"
: "Новая заявка на закупку"
: "Редактировать заявку"
}
size="lg"
@ -3459,7 +3669,11 @@ export default function ProcurementPage() {
disabled={busyKey === "save" || busyKey === "save-submit"}
className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
>
{modalMode === "create" ? "Создать заявку" : "Сохранить"}
{modalMode === "create"
? form.request_type === "subscription"
? "Создать заявку на согласование"
: "Создать заявку"
: "Сохранить"}
</button>
</div>
}
@ -3469,6 +3683,12 @@ export default function ProcurementPage() {
{actionError}
</p>
)}
{modalMode === "create" && form.request_type === "subscription" ? (
<p className="app-feedback-info mb-4 rounded-lg px-3 py-2 text-sm">
Заявка сразу отправится ответственному согласующему. Исполнители не
смогут взять её в работу, пока она не будет одобрена.
</p>
) : null}
<div className="flex flex-col gap-3">
{/* Название */}
@ -3547,6 +3767,48 @@ export default function ProcurementPage() {
}
/>
<div className="space-y-2">
<SearchableSelectMulti
label="Проекты"
placeholder="Выберите один или несколько проектов"
items={projects}
selectedIds={form.projects}
onToggle={(id) =>
setForm((current) => ({
...current,
projects: current.projects.includes(id)
? current.projects.filter((projectId) => projectId !== id)
: [...current.projects, id],
}))
}
/>
{canCreateProjects ? (
<div className="flex gap-2">
<input
value={newProjectName}
onChange={(event) => setNewProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleCreateProject();
}
}}
placeholder="Название нового проекта"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
aria-label="Название нового проекта"
/>
<button
type="button"
onClick={() => void handleCreateProject()}
disabled={creatingProject || !newProjectName.trim()}
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
{creatingProject ? "Создаём…" : "Создать проект"}
</button>
</div>
) : null}
</div>
{/* ── Items ── */}
<div>
<div className="mb-2 flex items-center justify-between">
@ -3561,6 +3823,11 @@ export default function ProcurementPage() {
<Plus size={13} /> Добавить
</button>
</div>
{form.request_type === "subscription" ? (
<p className="app-text-muted mb-2 text-xs">
Укажите срок количеством периодов, например: 12 месяцев.
</p>
) : null}
<div className="space-y-3">
{form.items.map((it, idx) => (
@ -3652,7 +3919,10 @@ export default function ProcurementPage() {
aria-label="Единица измерения"
className="app-select min-w-0 rounded-lg px-3 py-2 text-sm"
>
{getProcurementUnitOptions(it.unit).map((unit) => (
{getProcurementUnitOptions(
it.unit,
form.request_type === "subscription",
).map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>

View File

@ -30,6 +30,7 @@ import type {
UrgencyLevel,
User,
} from "@/types/api";
import type { ProjectOption } from "@/types/projects";
type ItemDraft = {
id?: number;
@ -63,20 +64,24 @@ const emptyItem: ItemDraft = {
type FormState = {
title: string;
description: string;
request_type: "standard" | "subscription";
department: number | null;
requireApproval: boolean;
processing_department: number | null;
urgency: UrgencyLevel;
projects: number[];
items: ItemDraft[];
};
const emptyForm: FormState = {
title: "",
description: "",
request_type: "standard",
department: null,
requireApproval: false,
processing_department: null,
urgency: "medium",
projects: [],
items: [{ ...emptyItem }],
};
@ -87,7 +92,8 @@ type ScopeTab =
| "processing_department"
| "pending_approvals"
| "my_work"
| "available";
| "available"
| "subscriptions";
type ProcurementSection = "requests" | "stats" | "suppliers";
type PaginatedLike<T> = {
@ -176,7 +182,19 @@ export function useProcurementPage(user: User | null) {
>([]);
const [defaultProcessingDepartmentId, setDefaultProcessingDepartmentId] =
useState<number | null>(null);
const [
subscriptionProcessingDepartments,
setSubscriptionProcessingDepartments,
] = useState<Department[]>([]);
const [
subscriptionDefaultProcessingDepartmentId,
setSubscriptionDefaultProcessingDepartmentId,
] = useState<number | null>(null);
const [employees, setEmployees] = useState<User[]>([]);
const [projects, setProjects] = useState<ProjectOption[]>([]);
const [canCreateProjects, setCanCreateProjects] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -191,6 +209,7 @@ export function useProcurementPage(user: User | null) {
pending_approvals: 0,
my_work: 0,
available: 0,
subscriptions: 0,
});
const [scope, setScope] = useState<ScopeTab>("all");
@ -204,6 +223,8 @@ export function useProcurementPage(user: User | null) {
const [requestorFilter, setRequestorFilter] = useState("");
const [executorFilter, setExecutorFilter] = useState("");
const [fulfillmentFilter, setFulfillmentFilter] = useState("");
const [projectFilter, setProjectFilter] = useState("");
const [deferredOnly, setDeferredOnly] = useState(false);
const [periodFilter, setPeriodFilter] = useState("");
const [dateFromFilter, setDateFromFilter] = useState("");
const [dateToFilter, setDateToFilter] = useState("");
@ -301,6 +322,7 @@ export function useProcurementPage(user: User | null) {
params.scope = "processing_department";
else if (scope === "my_work") params.scope = "my_work";
else if (scope === "available") params.scope = "available";
else if (scope === "subscriptions") params.scope = "subscriptions";
if (statusFilter.length === 1) params.status = statusFilter[0];
else if (statusFilter.length > 1)
params.status__in = statusFilter.join(",");
@ -311,6 +333,8 @@ export function useProcurementPage(user: User | null) {
if (requestorFilter) params.requestor = requestorFilter;
if (executorFilter) params.executor = executorFilter;
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
if (projectFilter) params.project = projectFilter;
if (deferredOnly) params.deferred = "true";
if (periodFilter) params.period = periodFilter;
if (dateFromFilter) params.date_from = dateFromFilter;
if (dateToFilter) params.date_to = dateToFilter;
@ -325,6 +349,8 @@ export function useProcurementPage(user: User | null) {
departmentFilter,
executorFilter,
fulfillmentFilter,
projectFilter,
deferredOnly,
periodFilter,
processingDepartmentFilter,
requestorFilter,
@ -343,6 +369,7 @@ export function useProcurementPage(user: User | null) {
params.scope = "processing_department";
else if (targetScope === "my_work") params.scope = "my_work";
else if (targetScope === "available") params.scope = "available";
else if (targetScope === "subscriptions") params.scope = "subscriptions";
if (statusFilter.length === 1) params.status = statusFilter[0];
else if (statusFilter.length > 1)
params.status__in = statusFilter.join(",");
@ -353,6 +380,8 @@ export function useProcurementPage(user: User | null) {
if (requestorFilter) params.requestor = requestorFilter;
if (executorFilter) params.executor = executorFilter;
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
if (projectFilter) params.project = projectFilter;
if (deferredOnly) params.deferred = "true";
if (periodFilter) params.period = periodFilter;
if (dateFromFilter) params.date_from = dateFromFilter;
if (dateToFilter) params.date_to = dateToFilter;
@ -367,6 +396,8 @@ export function useProcurementPage(user: User | null) {
departmentFilter,
executorFilter,
fulfillmentFilter,
projectFilter,
deferredOnly,
periodFilter,
processingDepartmentFilter,
requestorFilter,
@ -412,6 +443,7 @@ export function useProcurementPage(user: User | null) {
"pending_approvals",
"my_work",
"available",
"subscriptions",
];
const results = await Promise.all(
scopes.map(async (scopeKey) => {
@ -469,6 +501,32 @@ export function useProcurementPage(user: User | null) {
};
}, []);
useEffect(() => {
let cancelled = false;
void Promise.all([
loadAllPages<ProjectOption>((params) =>
apiClient.getLocalProjects("", params.page, false, 200),
),
apiClient.getProjectOptions(),
])
.then(([allProjects, projectOptions]) => {
if (cancelled) return;
setProjects(allProjects);
setCanCreateProjects(projectOptions.can_create);
})
.catch(() => {
if (!cancelled) {
setProjects([]);
setCanCreateProjects(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
@ -480,11 +538,19 @@ export function useProcurementPage(user: User | null) {
setDefaultProcessingDepartmentId(
options.default_processing_department ?? null,
);
setSubscriptionProcessingDepartments(
options.subscription_processing_departments || [],
);
setSubscriptionDefaultProcessingDepartmentId(
options.subscription_default_processing_department ?? null,
);
}
} catch {
if (!cancelled) {
setProcessingDepartments([]);
setDefaultProcessingDepartmentId(null);
setSubscriptionProcessingDepartments([]);
setSubscriptionDefaultProcessingDepartmentId(null);
}
}
})();
@ -521,11 +587,16 @@ export function useProcurementPage(user: User | null) {
};
}, []);
const activeDefaultProcessingDepartmentId =
form.request_type === "subscription"
? subscriptionDefaultProcessingDepartmentId
: defaultProcessingDepartmentId;
useEffect(() => {
if (
editingId !== null ||
form.requireApproval ||
!defaultProcessingDepartmentId
!activeDefaultProcessingDepartmentId
) {
return;
}
@ -533,16 +604,16 @@ export function useProcurementPage(user: User | null) {
setForm((previous) => {
if (
previous.requireApproval ||
previous.processing_department === defaultProcessingDepartmentId
previous.processing_department === activeDefaultProcessingDepartmentId
) {
return previous;
}
return {
...previous,
processing_department: defaultProcessingDepartmentId,
processing_department: activeDefaultProcessingDepartmentId,
};
});
}, [defaultProcessingDepartmentId, editingId, form.requireApproval]);
}, [activeDefaultProcessingDepartmentId, editingId, form.requireApproval]);
const filteredRequests = useMemo(() => {
const urgencyRank: Record<string, number> = {
@ -584,20 +655,35 @@ export function useProcurementPage(user: User | null) {
});
}, [ordering, requests]);
const resetForm = useCallback(() => {
setForm({
...emptyForm,
processing_department: defaultProcessingDepartmentId,
items: [{ ...emptyItem }],
});
}, [defaultProcessingDepartmentId]);
const resetForm = useCallback(
(requestType: FormState["request_type"] = "standard") => {
setForm({
...emptyForm,
request_type: requestType,
processing_department:
requestType === "subscription"
? subscriptionDefaultProcessingDepartmentId
: defaultProcessingDepartmentId,
items: [
{
...emptyItem,
unit: requestType === "subscription" ? "месяц" : emptyItem.unit,
},
],
});
},
[defaultProcessingDepartmentId, subscriptionDefaultProcessingDepartmentId],
);
const openCreate = useCallback(() => {
setEditingId(null);
resetForm();
setActionError(null);
setCreateOpen(true);
}, [resetForm]);
const openCreate = useCallback(
(requestType: FormState["request_type"] = "standard") => {
setEditingId(null);
resetForm(requestType);
setActionError(null);
setCreateOpen(true);
},
[resetForm],
);
const openEdit = useCallback(async (request: ProcurementRequest) => {
setCreateOpen(false);
@ -654,10 +740,12 @@ export function useProcurementPage(user: User | null) {
setForm({
title: detail.title || "",
description: detail.description || "",
request_type: detail.request_type || "standard",
department: detail.department ?? null,
requireApproval: false,
processing_department: detail.processing_department ?? null,
urgency: detail.urgency || "medium",
projects: detail.projects || [],
items:
existingItems.length > 0
? existingItems.map((item) => ({
@ -812,6 +900,8 @@ export function useProcurementPage(user: User | null) {
const payload: Record<string, unknown> = {
title: form.title,
description: form.description.trim(),
request_type: form.request_type,
projects: form.projects,
department: form.department,
processing_department: form.processing_department,
urgency: form.urgency,
@ -843,6 +933,7 @@ export function useProcurementPage(user: User | null) {
description: payload.description,
urgency: payload.urgency,
processing_department: payload.processing_department,
projects: payload.projects,
items: payload.items,
});
savedRequest = (await apiClient.getProcurementRequest(
@ -885,7 +976,9 @@ export function useProcurementPage(user: User | null) {
await refreshOne(savedRequest.id);
toast.success(
modalMode === "create"
? "Заявка создана и направлена в отдел."
? form.request_type === "subscription"
? "Заявка создана и отправлена на согласование."
: "Заявка создана и направлена в отдел."
: "Заявка обновлена.",
);
@ -1415,6 +1508,69 @@ export function useProcurementPage(user: User | null) {
[applyRequestStateUpdate, markRequestNotificationsRead],
);
const handleToggleDeferred = useCallback(
async (id: number, isDeferred: boolean) => {
try {
setBusyKey(`deferred-${id}`);
setActionError(null);
const updated = (await apiClient.setProcurementRequestDeferred(
id,
isDeferred,
)) as ProcurementRequest;
applyRequestStateUpdate(updated);
await refreshCurrentView();
return true;
} catch (deferredError) {
setActionError(
getReadableError(deferredError, "Не удалось обновить отметку заявки"),
);
return false;
} finally {
setBusyKey(null);
}
},
[applyRequestStateUpdate, refreshCurrentView],
);
const handleCreateProject = useCallback(async () => {
const name = newProjectName.trim();
if (!name) return;
if (!user?.id || !canCreateProjects) {
setActionError("У вас нет прав на создание проектов.");
return;
}
try {
setCreatingProject(true);
setActionError(null);
const created = await apiClient.saveLocalProject({
name,
description: "",
customer: "",
manager: user.id,
status: "planned",
starts_on: null,
ends_on: null,
});
setProjects((current) =>
[...current, { id: created.id, name: created.name }].sort(
(left, right) =>
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
),
);
setForm((current) => ({
...current,
projects: [...new Set([...current.projects, created.id])],
}));
setNewProjectName("");
} catch (projectError) {
setActionError(
getReadableError(projectError, "Не удалось создать проект"),
);
} finally {
setCreatingProject(false);
}
}, [canCreateProjects, newProjectName, user?.id]);
const handleUpdateItem = useCallback(
async (
requestId: number,
@ -1709,6 +1865,8 @@ export function useProcurementPage(user: User | null) {
requestorFilter,
executorFilter,
fulfillmentFilter,
projectFilter,
deferredOnly,
periodFilter,
dateFromFilter,
dateToFilter,
@ -1727,8 +1885,16 @@ export function useProcurementPage(user: User | null) {
requests,
departments,
processingDepartments,
subscriptionProcessingDepartments,
projects,
canCreateProjects,
newProjectName,
setNewProjectName,
creatingProject,
handleCreateProject,
employees,
defaultProcessingDepartmentId,
subscriptionDefaultProcessingDepartmentId,
loading,
loadingMore,
error,
@ -1756,6 +1922,10 @@ export function useProcurementPage(user: User | null) {
setExecutorFilter,
fulfillmentFilter,
setFulfillmentFilter,
projectFilter,
setProjectFilter,
deferredOnly,
setDeferredOnly,
periodFilter,
setPeriodFilter,
dateFromFilter,
@ -1806,6 +1976,7 @@ export function useProcurementPage(user: User | null) {
handleMarkAllReceived,
handleNotifyArrival,
handleToggleViewed,
handleToggleDeferred,
handleUpdateItem,
handleReportItemIssue,
handleCancelItemIssue,

View File

@ -98,6 +98,12 @@ export function createProcurementApi(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_viewed: isViewed }),
}),
setProcurementRequestDeferred: (id: number, isDeferred: boolean) =>
request(`/api/v1/procurement/requests/${id}/set_deferred/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_deferred: isDeferred }),
}),
markProcurementRequestNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>

View File

@ -26,11 +26,13 @@ export function createProjectsApi(request: RequestFn) {
search = "",
page = 1,
mine = false,
pageSize = 200,
): Promise<{
count: number;
next: string | null;
results: LocalProject[];
}> => request(root + buildQuery({ search, page, mine })),
}> =>
request(root + buildQuery({ search, page, mine, page_size: pageSize })),
getLocalProject: (id: number): Promise<LocalProject> =>
request(`${root}${id}/`),
getProjectOptions: (): Promise<{

View File

@ -17,6 +17,13 @@ export const procurementUnitOptions = [
{ value: "час", label: "час" },
];
export const subscriptionPeriodUnitOptions = [
{ value: "день", label: "день" },
{ value: "неделя", label: "неделя" },
{ value: "месяц", label: "месяц" },
{ value: "год", label: "год" },
];
const procurementUnitLabels: Record<string, string> = {
шт: "штуки",
компл: "комплекты",
@ -34,6 +41,10 @@ const procurementUnitLabels: Record<string, string> = {
пара: "пары",
услуга: "услуги",
час: "часы",
день: "дней",
неделя: "недель",
месяц: "месяцев",
год: "лет",
};
type UnitAwareItem = {
@ -48,17 +59,23 @@ const cleanUnitValue = (value?: string | null) =>
const normalizeUnit = (value?: string | null) =>
cleanUnitValue(value).toLowerCase();
export const getProcurementUnitOptions = (currentUnit: string) => {
export const getProcurementUnitOptions = (
currentUnit: string,
includeSubscriptionPeriods = false,
) => {
const currentValue = cleanUnitValue(currentUnit);
const availableOptions = includeSubscriptionPeriods
? [...procurementUnitOptions, ...subscriptionPeriodUnitOptions]
: procurementUnitOptions;
if (
!currentValue ||
procurementUnitOptions.some((unit) => unit.value === currentValue)
availableOptions.some((unit) => unit.value === currentValue)
) {
return procurementUnitOptions;
return availableOptions;
}
return [
...procurementUnitOptions,
...availableOptions,
{ value: currentValue, label: `${currentValue} (текущее)` },
];
};

View File

@ -929,6 +929,8 @@ export interface Department {
export interface ProcurementCreateOptions {
processing_departments: Pick<Department, "id" | "name">[];
default_processing_department: number | null;
subscription_processing_departments: Pick<Department, "id" | "name">[];
subscription_default_processing_department: number | null;
}
export interface DepartmentMemberRole {
@ -1586,6 +1588,9 @@ export interface ProcurementApprovalOptions {
export interface ProcurementRequest {
id: number;
request_type?: "standard" | "subscription";
projects?: number[];
project_names?: string[];
title: string;
description: string;
department: number;
@ -1610,6 +1615,7 @@ export interface ProcurementRequest {
actual_cost?: string | number | null;
is_editable?: boolean;
is_viewed?: boolean;
is_deferred?: boolean;
total_cost?: string | number;
total_estimated_cost?: string | number;
items_count?: number;