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 && ( + {getStatusMeta(selectedRequest.status).label} + {(selectedRequest.project_names || []).map((name) => ( + + {name} + + ))} @@ -2066,7 +2232,9 @@ export default function ProcurementPage() { className="app-text-muted mx-auto mb-2" />

- Заявок на закупку не найдено + {scope === "subscriptions" + ? "Заявок на подписку не найдено" + : "Заявок на закупку не найдено"}

) : ( @@ -2338,6 +2506,14 @@ export default function ProcurementPage() {
{requestDateLabel} + {(req.project_names || []).map((name) => ( + + {name} + + ))} {getRequestAmount(req) && ( {money(getRequestAmount(req))} @@ -2374,6 +2550,38 @@ export default function ProcurementPage() { > {sMeta.label} + {showRequestMenu ? (
} @@ -3469,6 +3683,12 @@ export default function ProcurementPage() { {actionError}

)} + {modalMode === "create" && form.request_type === "subscription" ? ( +

+ Заявка сразу отправится ответственному согласующему. Исполнители не + смогут взять её в работу, пока она не будет одобрена. +

+ ) : null}
{/* Название */} @@ -3547,6 +3767,48 @@ export default function ProcurementPage() { } /> +
+ + setForm((current) => ({ + ...current, + projects: current.projects.includes(id) + ? current.projects.filter((projectId) => projectId !== id) + : [...current.projects, id], + })) + } + /> + {canCreateProjects ? ( +
+ 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="Название нового проекта" + /> + +
+ ) : null} +
+ {/* ── 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;