From 18d170ffd2866845936d02f24f4ffc32e1616912 Mon Sep 17 00:00:00 2001
From: lizerk
Date: Sun, 25 Oct 2026 15:35:46 +0300
Subject: [PATCH] Add subscription and project-aware procurement workflows
---
.../v1/procurement/serializers/approvals.py | 4 +
.../api/v1/procurement/serializers/common.py | 24 +-
.../procurement/serializers/request_mixins.py | 23 ++
.../procurement/serializers/request_read.py | 28 +-
.../procurement/serializers/request_write.py | 42 ++-
.../viewsets/mixins/request_execution.py | 24 ++
.../viewsets/mixins/request_query.py | 40 ++-
.../viewsets/mixins/request_workflow.py | 24 +-
.../api/v1/procurement/viewsets/requests.py | 4 +
backend/procurement/admin.py | 34 +-
backend/procurement/application/requests.py | 35 +-
.../migrations/0020_subscription_requests.py | 37 ++
...021_subscription_processing_departments.py | 39 ++
...0022_procurement_projects_deferred_flag.py | 28 ++
backend/procurement/models.py | 71 +++-
backend/procurement/services.py | 14 +
frontend/src/app/login/page.tsx | 17 +-
frontend/src/app/procurement/page.tsx | 338 ++++++++++++++++--
frontend/src/hooks/useProcurementPage.ts | 209 ++++++++++-
frontend/src/lib/api/procurement.ts | 6 +
frontend/src/lib/api/projects.ts | 4 +-
frontend/src/lib/procurementUnits.ts | 25 +-
frontend/src/types/api.ts | 6 +
23 files changed, 994 insertions(+), 82 deletions(-)
create mode 100644 backend/procurement/migrations/0020_subscription_requests.py
create mode 100644 backend/procurement/migrations/0021_subscription_processing_departments.py
create mode 100644 backend/procurement/migrations/0022_procurement_projects_deferred_flag.py
diff --git a/backend/api/v1/procurement/serializers/approvals.py b/backend/api/v1/procurement/serializers/approvals.py
index a7c60bbf..09d6d152 100644
--- a/backend/api/v1/procurement/serializers/approvals.py
+++ b/backend/api/v1/procurement/serializers/approvals.py
@@ -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):
diff --git a/backend/api/v1/procurement/serializers/common.py b/backend/api/v1/procurement/serializers/common.py
index fb34b271..d21e340c 100644
--- a/backend/api/v1/procurement/serializers/common.py
+++ b/backend/api/v1/procurement/serializers/common.py
@@ -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):
diff --git a/backend/api/v1/procurement/serializers/request_mixins.py b/backend/api/v1/procurement/serializers/request_mixins.py
index 51bfb92d..e44c14f0 100644
--- a/backend/api/v1/procurement/serializers/request_mixins.py
+++ b/backend/api/v1/procurement/serializers/request_mixins.py
@@ -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)
diff --git a/backend/api/v1/procurement/serializers/request_read.py b/backend/api/v1/procurement/serializers/request_read.py
index 99bfc3d8..e2f2d47a 100644
--- a/backend/api/v1/procurement/serializers/request_read.py
+++ b/backend/api/v1/procurement/serializers/request_read.py
@@ -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",
diff --git a/backend/api/v1/procurement/serializers/request_write.py b/backend/api/v1/procurement/serializers/request_write.py
index 6dee8c8d..75f0a2fa 100644
--- a/backend/api/v1/procurement/serializers/request_write.py
+++ b/backend/api/v1/procurement/serializers/request_write.py
@@ -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
diff --git a/backend/api/v1/procurement/viewsets/mixins/request_execution.py b/backend/api/v1/procurement/viewsets/mixins/request_execution.py
index 9f7b9b48..1dd09622 100644
--- a/backend/api/v1/procurement/viewsets/mixins/request_execution.py
+++ b/backend/api/v1/procurement/viewsets/mixins/request_execution.py
@@ -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="Отметить уведомления по заявке как прочитанные",
diff --git a/backend/api/v1/procurement/viewsets/mixins/request_query.py b/backend/api/v1/procurement/viewsets/mixins/request_query.py
index 4ef848c5..395c566e 100644
--- a/backend/api/v1/procurement/viewsets/mixins/request_query.py
+++ b/backend/api/v1/procurement/viewsets/mixins/request_query.py
@@ -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()
diff --git a/backend/api/v1/procurement/viewsets/mixins/request_workflow.py b/backend/api/v1/procurement/viewsets/mixins/request_workflow.py
index 1efe4ae0..aef6d9ce 100644
--- a/backend/api/v1/procurement/viewsets/mixins/request_workflow.py
+++ b/backend/api/v1/procurement/viewsets/mixins/request_workflow.py
@@ -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
diff --git a/backend/api/v1/procurement/viewsets/requests.py b/backend/api/v1/procurement/viewsets/requests.py
index 2adf826f..6066acfe 100644
--- a/backend/api/v1/procurement/viewsets/requests.py
+++ b/backend/api/v1/procurement/viewsets/requests.py
@@ -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",
}:
diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py
index b124687b..dc9b5913 100644
--- a/backend/procurement/admin.py
+++ b/backend/procurement/admin.py
@@ -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",
diff --git a/backend/procurement/application/requests.py b/backend/procurement/application/requests.py
index 3d085507..8a4e7d64 100644
--- a/backend/procurement/application/requests.py
+++ b/backend/procurement/application/requests.py
@@ -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:
diff --git a/backend/procurement/migrations/0020_subscription_requests.py b/backend/procurement/migrations/0020_subscription_requests.py
new file mode 100644
index 00000000..2804320d
--- /dev/null
+++ b/backend/procurement/migrations/0020_subscription_requests.py
@@ -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="Согласующий заявки на подписки",
+ ),
+ ),
+ ]
diff --git a/backend/procurement/migrations/0021_subscription_processing_departments.py b/backend/procurement/migrations/0021_subscription_processing_departments.py
new file mode 100644
index 00000000..91d6f734
--- /dev/null
+++ b/backend/procurement/migrations/0021_subscription_processing_departments.py
@@ -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="Допустимые отделы-исполнители для подписок",
+ ),
+ ),
+ ]
diff --git a/backend/procurement/migrations/0022_procurement_projects_deferred_flag.py b/backend/procurement/migrations/0022_procurement_projects_deferred_flag.py
new file mode 100644
index 00000000..3152dac6
--- /dev/null
+++ b/backend/procurement/migrations/0022_procurement_projects_deferred_flag.py
@@ -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="Отложено для себя"),
+ ),
+ ]
diff --git a/backend/procurement/models.py b/backend/procurement/models.py
index d2019feb..ca9a7632 100644
--- a/backend/procurement/models.py
+++ b/backend/procurement/models.py
@@ -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,
diff --git a/backend/procurement/services.py b/backend/procurement/services.py
index a12be570..37e9c700 100644
--- a/backend/procurement/services.py
+++ b/backend/procurement/services.py
@@ -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,
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
index 00cb5833..1e9899e2 100644
--- a/frontend/src/app/login/page.tsx
+++ b/frontend/src/app/login/page.tsx
@@ -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;
diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx
index e22d5193..b0021ece 100644
--- a/frontend/src/app/procurement/page.tsx
+++ b/frontend/src/app/procurement/page.tsx
@@ -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() {
{activeSection === "requests" && (
+ openCreate(
+ scope === "subscriptions" ? "subscription" : "standard",
+ )
+ }
icon={}
- label="Создать заявку"
+ label={
+ scope === "subscriptions"
+ ? "Новая подписка"
+ : "Создать заявку"
+ }
/>
)}
@@ -1169,31 +1200,55 @@ export default function ProcurementPage() {
- {scopeTabs.map((tab) => (
-
+
+
+
+ {scope === "subscriptions" ? null : (
+
+ {scopeTabs.map((tab) => (
+
- ))}
-
+ {tab.label}
+
+ {scopeCounts[tab.value] ?? 0}
+
+
+ ))}
+
+ )}
>
) : null}
@@ -1395,6 +1450,24 @@ export default function ProcurementPage() {
/>
+
+
+
{activeFilterCount > 0 && (
+
)}
+ {modalMode === "create" && form.request_type === "subscription" ? (
+
+ Заявка сразу отправится ответственному согласующему. Исполнители не
+ смогут взять её в работу, пока она не будет одобрена.
+
+ ) : null}
{/* Название */}
@@ -3547,6 +3767,48 @@ export default function ProcurementPage() {
}
/>
+
+
{/* ── Items ── */}
@@ -3561,6 +3823,11 @@ export default function ProcurementPage() {
Добавить
+ {form.request_type === "subscription" ? (
+
+ Укажите срок количеством периодов, например: 12 месяцев.
+
+ ) : null}
{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) => (
diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts
index 0221baff..f1a28977 100644
--- a/frontend/src/hooks/useProcurementPage.ts
+++ b/frontend/src/hooks/useProcurementPage.ts
@@ -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
= {
@@ -176,7 +182,19 @@ export function useProcurementPage(user: User | null) {
>([]);
const [defaultProcessingDepartmentId, setDefaultProcessingDepartmentId] =
useState(null);
+ const [
+ subscriptionProcessingDepartments,
+ setSubscriptionProcessingDepartments,
+ ] = useState([]);
+ const [
+ subscriptionDefaultProcessingDepartmentId,
+ setSubscriptionDefaultProcessingDepartmentId,
+ ] = useState(null);
const [employees, setEmployees] = useState([]);
+ const [projects, setProjects] = useState([]);
+ 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(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("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((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 = {
@@ -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 = {
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,
diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts
index 29fe4c18..81c7aa86 100644
--- a/frontend/src/lib/api/procurement.ts
+++ b/frontend/src/lib/api/procurement.ts
@@ -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[] }> =>
diff --git a/frontend/src/lib/api/projects.ts b/frontend/src/lib/api/projects.ts
index 4e3885af..2ff5e2f9 100644
--- a/frontend/src/lib/api/projects.ts
+++ b/frontend/src/lib/api/projects.ts
@@ -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 =>
request(`${root}${id}/`),
getProjectOptions: (): Promise<{
diff --git a/frontend/src/lib/procurementUnits.ts b/frontend/src/lib/procurementUnits.ts
index fefba652..c0ed5662 100644
--- a/frontend/src/lib/procurementUnits.ts
+++ b/frontend/src/lib/procurementUnits.ts
@@ -17,6 +17,13 @@ export const procurementUnitOptions = [
{ value: "час", label: "час" },
];
+export const subscriptionPeriodUnitOptions = [
+ { value: "день", label: "день" },
+ { value: "неделя", label: "неделя" },
+ { value: "месяц", label: "месяц" },
+ { value: "год", label: "год" },
+];
+
const procurementUnitLabels: Record = {
шт: "штуки",
компл: "комплекты",
@@ -34,6 +41,10 @@ const procurementUnitLabels: Record = {
пара: "пары",
услуга: "услуги",
час: "часы",
+ день: "дней",
+ неделя: "недель",
+ месяц: "месяцев",
+ год: "лет",
};
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} (текущее)` },
];
};
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 20966cf5..333fdb06 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -929,6 +929,8 @@ export interface Department {
export interface ProcurementCreateOptions {
processing_departments: Pick[];
default_processing_department: number | null;
+ subscription_processing_departments: Pick[];
+ 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;