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" && (
+ Заявка сразу отправится ответственному согласующему. Исполнители не + смогут взять её в работу, пока она не будет одобрена. +
+ ) : null}+ Укажите срок количеством периодов, например: 12 месяцев. +
+ ) : null}