From c6f495fc3cb62aada97d7929c3037c098f1a0b82 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 26 Aug 2026 12:53:43 +0300 Subject: [PATCH] feat: add vacation approval terms workflow --- backend/api/v1/requests_app/permissions.py | 42 +-- .../request_serializers/__init__.py | 6 + .../requests_app/request_serializers/read.py | 29 ++ .../request_serializers/vacation.py | 45 +++ backend/api/v1/requests_app/views.py | 4 + .../api/v1/requests_app/viewsets/__init__.py | 2 + backend/api/v1/requests_app/viewsets/core.py | 9 +- .../v1/requests_app/viewsets/statistics.py | 8 +- .../api/v1/requests_app/viewsets/vacation.py | 92 +++++ backend/finance/admin.py | 34 +- backend/finance/enums.py | 7 + ...6_alter_employeepayrate_source_and_more.py | 39 +++ backend/finance/models.py | 65 ++++ backend/requests_app/admin.py | 21 +- backend/requests_app/application/__init__.py | 8 + .../requests_app/application/statistics.py | 75 ++-- backend/requests_app/application/vacation.py | 182 ++++++++++ backend/requests_app/enums.py | 13 +- .../migrations/0016_requestvacationterms.py | 32 ++ backend/requests_app/models.py | 61 +++- .../api/v1/requests_app/test_requests_api.py | 330 ++++++++++++------ frontend/src/app/requests/page.tsx | 8 + .../requests/RequestDetailModal.tsx | 21 +- .../requests/RequestDetailPanel.tsx | 20 +- .../requests/RequestVacationTermsModal.tsx | 266 ++++++++++++++ frontend/src/hooks/useRequestsPage.ts | 21 +- frontend/src/lib/api/requests.ts | 6 +- frontend/src/types/api.ts | 35 +- 28 files changed, 1315 insertions(+), 166 deletions(-) create mode 100644 backend/api/v1/requests_app/request_serializers/vacation.py create mode 100644 backend/api/v1/requests_app/viewsets/vacation.py create mode 100644 backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py create mode 100644 backend/requests_app/application/vacation.py create mode 100644 backend/requests_app/migrations/0016_requestvacationterms.py create mode 100644 frontend/src/components/requests/RequestVacationTermsModal.tsx diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index 64581943..3b1cac57 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -20,9 +20,7 @@ class IsRecipientOfRequest(BasePermission): def has_permission(self, request: Request, view: Any) -> bool: return bool(getattr(request.user, "is_authenticated", False)) - def has_object_permission( - self, request: Request, view: Any, obj: Any - ) -> bool: + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # Решение можно принимать только по заявке в статусе pending # и только прямому получателю (не cc_users). return ( @@ -39,9 +37,7 @@ class CanViewRequest(BasePermission): def has_permission(self, request: Request, view: Any) -> bool: return bool(getattr(request.user, "is_authenticated", False)) - def has_object_permission( - self, request: Request, view: Any, obj: Any - ) -> bool: + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: user = request.user # Черновик - только авторское состояние. @@ -71,9 +67,7 @@ class IsRequestAuthor(BasePermission): def has_permission(self, request: Request, view: Any) -> bool: return bool(getattr(request.user, "is_authenticated", False)) - def has_object_permission( - self, request: Request, view: Any, obj: Any - ) -> bool: + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: return getattr(obj, "employee_id", None) == request.user.id @@ -91,22 +85,14 @@ class CommentsPermission(BasePermission): if getattr(obj, "employee_id", None) == user.id: return True # Получатель заявки - if ( - hasattr(obj, "recipients") - and obj.recipients.filter(id=user.id).exists() - ): + if hasattr(obj, "recipients") and obj.recipients.filter(id=user.id).exists(): return True # В копии - if ( - hasattr(obj, "cc_users") - and obj.cc_users.filter(id=user.id).exists() - ): + if hasattr(obj, "cc_users") and obj.cc_users.filter(id=user.id).exists(): return True return False - def has_object_permission( - self, request: Request, view: Any, obj: Any - ) -> bool: + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: if getattr(obj, "status", None) == "draft": return False return self._is_participant(request.user, obj) @@ -117,9 +103,19 @@ class NotFinalOrStaff(BasePermission): message = "Финальная заявка не может быть удалена." - def has_object_permission( - self, request: Request, view: Any, obj: Any - ) -> bool: + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: if request.method != "DELETE": return True return not bool(getattr(obj, "is_final", False)) + + +class CanManageVacationTerms(BasePermission): + message = "У вас нет права изменять условия этого отпуска." + + def has_permission(self, request: Request, view: Any) -> bool: + return bool(getattr(request.user, "is_authenticated", False)) + + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: + from requests_app.application import can_manage_vacation_terms + + return can_manage_vacation_terms(request.user, obj) diff --git a/backend/api/v1/requests_app/request_serializers/__init__.py b/backend/api/v1/requests_app/request_serializers/__init__.py index f5291b1c..0e739155 100644 --- a/backend/api/v1/requests_app/request_serializers/__init__.py +++ b/backend/api/v1/requests_app/request_serializers/__init__.py @@ -1,10 +1,16 @@ from .fields import RecipientIDsField from .read import RequestProfileSummarySerializer, RequestReadSerializer from .write import RequestWriteSerializer +from .vacation import ( + RequestVacationTermsReadSerializer, + RequestVacationTermsWriteSerializer, +) __all__ = [ "RecipientIDsField", "RequestProfileSummarySerializer", "RequestReadSerializer", "RequestWriteSerializer", + "RequestVacationTermsReadSerializer", + "RequestVacationTermsWriteSerializer", ] diff --git a/backend/api/v1/requests_app/request_serializers/read.py b/backend/api/v1/requests_app/request_serializers/read.py index 32bf52c7..c65cda8e 100644 --- a/backend/api/v1/requests_app/request_serializers/read.py +++ b/backend/api/v1/requests_app/request_serializers/read.py @@ -7,9 +7,12 @@ from django.db import models from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from requests_app.application import can_manage_vacation_terms +from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request from ...employees.serializers import EmployeeBriefSerializer +from .vacation import RequestVacationTermsReadSerializer User = get_user_model() logger = logging.getLogger(__name__) @@ -45,6 +48,9 @@ class RequestReadSerializer(serializers.ModelSerializer): starts_at = serializers.DateTimeField(read_only=True) ends_at = serializers.DateTimeField(read_only=True) duration_minutes = serializers.IntegerField(read_only=True) + vacation_terms = serializers.SerializerMethodField() + vacation_terms_required = serializers.SerializerMethodField() + can_manage_vacation_terms = serializers.SerializerMethodField() class Meta: model = Request @@ -82,6 +88,9 @@ class RequestReadSerializer(serializers.ModelSerializer): "can_decide", "comments_count", "linked_tasks", + "vacation_terms", + "vacation_terms_required", + "can_manage_vacation_terms", ) read_only_fields = ( "employee", @@ -101,6 +110,9 @@ class RequestReadSerializer(serializers.ModelSerializer): "can_decide", "comments_count", "linked_tasks", + "vacation_terms", + "vacation_terms_required", + "can_manage_vacation_terms", ) @extend_schema_field(serializers.IntegerField()) @@ -168,6 +180,23 @@ class RequestReadSerializer(serializers.ModelSerializer): user=user, ) + def get_vacation_terms(self, obj): + terms = getattr(obj, "vacation_terms", None) + if terms is None: + return None + return RequestVacationTermsReadSerializer(terms).data + + def get_vacation_terms_required(self, obj): + return bool( + obj.type == RequestType.VACATION + and obj.status == RequestStatus.APPROVED + and getattr(obj, "vacation_terms", None) is None + ) + + def get_can_manage_vacation_terms(self, obj): + request = self.context.get("request") + return can_manage_vacation_terms(getattr(request, "user", None), obj) + class RequestProfileSummarySerializer(serializers.ModelSerializer): """Компактное представление заявления для профиля сотрудника.""" diff --git a/backend/api/v1/requests_app/request_serializers/vacation.py b/backend/api/v1/requests_app/request_serializers/vacation.py new file mode 100644 index 00000000..0777e7da --- /dev/null +++ b/backend/api/v1/requests_app/request_serializers/vacation.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + +from requests_app.models import RequestVacationTerms + +from ...employees.serializers import EmployeeBriefSerializer + + +class RequestVacationTermsReadSerializer(serializers.ModelSerializer): + recorded_by = EmployeeBriefSerializer(read_only=True) + total_days = serializers.IntegerField(read_only=True) + unpaid_days = serializers.IntegerField(read_only=True) + finance_instruction = serializers.SerializerMethodField() + + class Meta: + model = RequestVacationTerms + fields = ( + "paid_days", + "unpaid_days", + "total_days", + "finance_status", + "recorded_by", + "recorded_at", + "updated_at", + "finance_instruction", + ) + + def get_finance_instruction(self, obj): + instruction = getattr(obj, "finance_instruction", None) + if instruction is None: + return None + return { + "id": instruction.id, + "status": instruction.status, + "paid_days": instruction.paid_days, + "amount": str(instruction.amount) + if instruction.amount is not None + else None, + "period_id": instruction.period_id, + "payroll_input_line_id": instruction.payroll_input_line_id, + } + + +class RequestVacationTermsWriteSerializer(serializers.Serializer): + paid_days = serializers.IntegerField(min_value=0) + record_to_finance = serializers.BooleanField() diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 04bc9285..dcbd5f50 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -9,6 +9,7 @@ from .viewsets import ( RequestNotificationsMixin, RequestStatisticsMixin, RequestWorkflowMixin, + RequestVacationMixin, ) @@ -16,6 +17,7 @@ class RequestViewSet( RequestCoreMixin, RequestCommentsMixin, RequestWorkflowMixin, + RequestVacationMixin, RequestStatisticsMixin, RequestNotificationsMixin, viewsets.ModelViewSet, @@ -39,5 +41,7 @@ class RequestViewSet( "employee", "approver", "department", + "vacation_terms__recorded_by", + "vacation_terms__finance_instruction", ).order_by("-created_at") parser_classes = (MultiPartParser, FormParser, JSONParser) diff --git a/backend/api/v1/requests_app/viewsets/__init__.py b/backend/api/v1/requests_app/viewsets/__init__.py index 138454e4..e28cab75 100644 --- a/backend/api/v1/requests_app/viewsets/__init__.py +++ b/backend/api/v1/requests_app/viewsets/__init__.py @@ -3,6 +3,7 @@ from .core import RequestCoreMixin from .notifications import RequestNotificationsMixin from .statistics import RequestStatisticsMixin from .workflow import RequestWorkflowMixin +from .vacation import RequestVacationMixin __all__ = [ "RequestCommentsMixin", @@ -10,4 +11,5 @@ __all__ = [ "RequestNotificationsMixin", "RequestStatisticsMixin", "RequestWorkflowMixin", + "RequestVacationMixin", ] diff --git a/backend/api/v1/requests_app/viewsets/core.py b/backend/api/v1/requests_app/viewsets/core.py index c632f2f2..16cbc30e 100644 --- a/backend/api/v1/requests_app/viewsets/core.py +++ b/backend/api/v1/requests_app/viewsets/core.py @@ -17,6 +17,7 @@ from requests_app.models import Request as EmployeeRequest from requests_app.models import RequestSettings from ..permissions import ( + CanManageVacationTerms, CanViewRequest, CommentsPermission, IsRecipientOfRequest, @@ -42,6 +43,7 @@ class RequestCoreMixin: "retrieve": CanViewRequest, "employee_statistics": CanViewRequest, "mark_notifications_read": CanViewRequest, + "vacation_terms": CanManageVacationTerms, } if self.action == "destroy": return [IsRequestAuthor(), NotFinalOrStaff()] @@ -219,12 +221,17 @@ class RequestCoreMixin: "retrieve", "employee_statistics", "mark_notifications_read", + "vacation_terms", } if getattr(self, "action", None) in detail_actions: lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] queryset = EmployeeRequest.objects.select_related( - "employee", "approver", "department" + "employee", + "approver", + "department", + "vacation_terms__recorded_by", + "vacation_terms__finance_instruction", ) obj = get_object_or_404( queryset, diff --git a/backend/api/v1/requests_app/viewsets/statistics.py b/backend/api/v1/requests_app/viewsets/statistics.py index 514353e4..bbb4cbdf 100644 --- a/backend/api/v1/requests_app/viewsets/statistics.py +++ b/backend/api/v1/requests_app/viewsets/statistics.py @@ -19,9 +19,9 @@ from ..serializers import RequestProfileSummarySerializer def _validated_period(request): period = (request.query_params.get("period") or "all").strip().lower() - if period not in {"all", "year", "month", "custom"}: + if period not in {"all", "half_year", "year", "month", "custom"}: raise ValidationError( - "Параметр period должен быть one of: all, year, month, custom." + "Параметр period должен быть one of: all, half_year, year, month, custom." ) return period @@ -48,7 +48,9 @@ class RequestStatisticsMixin: page_raw = (request.query_params.get("page") or "1").strip() if not page_raw.isdigit() or int(page_raw) < 1: - raise ValidationError("Параметр page должен быть положительным целым числом.") + raise ValidationError( + "Параметр page должен быть положительным целым числом." + ) page = int(page_raw) period = _validated_period(request) diff --git a/backend/api/v1/requests_app/viewsets/vacation.py b/backend/api/v1/requests_app/viewsets/vacation.py new file mode 100644 index 00000000..25959d43 --- /dev/null +++ b/backend/api/v1/requests_app/viewsets/vacation.py @@ -0,0 +1,92 @@ +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from requests_app.application import ( + build_employee_statistics, + resolve_stats_window_from_query, + save_vacation_terms, + vacation_total_days, +) +from requests_app.enums import RequestStatus, RequestType +from requests_app.models import Request as EmployeeRequest + +from ..serializers import ( + RequestReadSerializer, + RequestVacationTermsReadSerializer, + RequestVacationTermsWriteSerializer, +) + + +VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"} + + +class RequestVacationMixin: + @action( + detail=True, + methods=["get", "put", "patch"], + url_path="vacation-terms", + ) + def vacation_terms(self, request, pk=None): + request_obj = self.get_object() + if request_obj.type != RequestType.VACATION: + raise ValidationError("Условия оплаты доступны только для отпуска.") + if request_obj.status != RequestStatus.APPROVED: + raise ValidationError("Сначала одобрите заявление на отпуск.") + + if request.method == "GET": + return Response(self._vacation_terms_context(request_obj, request)) + + serializer = RequestVacationTermsWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + save_vacation_terms( + request_obj=request_obj, + actor=request.user, + paid_days=serializer.validated_data["paid_days"], + record_to_finance=serializer.validated_data["record_to_finance"], + ) + refreshed = EmployeeRequest.objects.select_related( + "employee", + "approver", + "department", + "vacation_terms__recorded_by", + "vacation_terms__finance_instruction", + ).get(pk=request_obj.pk) + return Response( + { + "request": RequestReadSerializer( + refreshed, + context=self.get_serializer_context(), + ).data, + **self._vacation_terms_context(refreshed, request), + } + ) + + @staticmethod + def _vacation_terms_context(request_obj, request): + period = (request.query_params.get("period") or "half_year").strip().lower() + if period not in VACATION_STATS_PERIODS: + raise ValidationError( + "Параметр period должен быть one of: half_year, year, month, all, custom." + ) + start_date, end_date = resolve_stats_window_from_query( + period, + request.query_params, + ) + terms = getattr(request_obj, "vacation_terms", None) + return { + "request_id": request_obj.id, + "total_days": vacation_total_days(request_obj), + "terms": ( + RequestVacationTermsReadSerializer(terms).data + if terms is not None + else None + ), + "statistics": build_employee_statistics( + employee_id=request_obj.employee_id, + employee_name=str(request_obj.employee), + period=period, + start_date=start_date, + end_date=end_date, + ), + } diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 92e80e11..5f8b5aee 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -22,6 +22,7 @@ from .models import ( PayrollStatementLine, PayrollWorkSettings, PayrollWorkRecord, + VacationPayInstruction, ) from .payroll.access import has_payroll_permission, has_simple_admin_access from .payroll.exceptions import PayrollOperationError @@ -408,9 +409,11 @@ class EmployeePayRateAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): @transaction.atomic def _delete_revision_chains(self, records): chain_ids = self._revision_chain_ids(records) - revisions = EmployeePayRate.objects.select_for_update().filter( - pk__in=chain_ids - ).order_by("-revision", "-pk") + revisions = ( + EmployeePayRate.objects.select_for_update() + .filter(pk__in=chain_ids) + .order_by("-revision", "-pk") + ) for revision in revisions: revision.delete() @@ -602,6 +605,31 @@ class PayrollInputLineAdmin(ImmutableApprovedAdminMixin, admin.ModelAdmin): ) +@admin.register(VacationPayInstruction) +class VacationPayInstructionAdmin(admin.ModelAdmin): + list_display = ( + "vacation_terms", + "paid_days", + "period", + "amount", + "status", + "created_at", + ) + list_filter = ("status", "period") + search_fields = ( + "vacation_terms__request__employee__last_name", + "vacation_terms__request__employee__first_name", + "vacation_terms__request__employee__email", + ) + autocomplete_fields = ( + "vacation_terms", + "period", + "payroll_input_line", + "created_by", + ) + readonly_fields = ("created_at", "updated_at") + + class PayrollStatementLineInline(admin.TabularInline): model = PayrollStatementLine extra = 0 diff --git a/backend/finance/enums.py b/backend/finance/enums.py index dc508f8c..faad5e08 100644 --- a/backend/finance/enums.py +++ b/backend/finance/enums.py @@ -16,6 +16,13 @@ class InputSource(models.TextChoices): ATTENDANCE = "attendance", "Посещаемость" +class VacationPayInstructionStatus(models.TextChoices): + PENDING_AMOUNT = "pending_amount", "Ожидает расчёта суммы" + DRAFT = "draft", "Черновик начисления" + POSTED = "posted", "Передано в начисления" + VOIDED = "voided", "Отменено" + + class PayrollComponentKind(models.TextChoices): EARNING = "earning", "Начисление" ADJUSTMENT_CREDIT = "adjustment_credit", "Коррекция +" diff --git a/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py b/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py new file mode 100644 index 00000000..4516da7d --- /dev/null +++ b/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.4 on 2026-08-26 09:39 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0025_payroll_components_in_code'), + ('requests_app', '0016_requestvacationterms'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VacationPayInstruction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('paid_days', models.PositiveSmallIntegerField(verbose_name='Оплачиваемые дни')), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=19, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма отпускных')), + ('status', models.CharField(choices=[('pending_amount', 'Ожидает расчёта суммы'), ('draft', 'Черновик начисления'), ('posted', 'Передано в начисления'), ('voided', 'Отменено')], default='pending_amount', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_vacation_pay_instructions', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('payroll_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instruction', to='finance.payrollinputline', verbose_name='Строка начисления')), + ('period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instructions', to='finance.payrollperiod', verbose_name='Расчётный период')), + ('vacation_terms', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='finance_instruction', to='requests_app.requestvacationterms', verbose_name='Условия отпуска')), + ], + options={ + 'verbose_name': 'Поручение на отпускные', + 'verbose_name_plural': 'Поручения на отпускные', + 'ordering': ['-created_at', '-id'], + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 96fa5326..75268f94 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -23,6 +23,7 @@ from .enums import ( PayrollComponentKind, PayrollPeriodStatus, PayrollRunStatus, + VacationPayInstructionStatus, ) currency_validator = RegexValidator( @@ -1112,6 +1113,70 @@ class PayrollInputLine(DraftVersionedModel): ) +class VacationPayInstruction(models.Model): + """Поручение финансам рассчитать отпускные по согласованным условиям.""" + + vacation_terms = models.OneToOneField( + "requests_app.RequestVacationTerms", + on_delete=models.PROTECT, + related_name="finance_instruction", + verbose_name="Условия отпуска", + ) + period = models.ForeignKey( + PayrollPeriod, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="vacation_pay_instructions", + verbose_name="Расчётный период", + ) + paid_days = models.PositiveSmallIntegerField("Оплачиваемые дни") + amount = models.DecimalField( + "Сумма отпускных", + max_digits=19, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(Decimal("0.01"))], + ) + status = models.CharField( + "Статус", + max_length=20, + choices=VacationPayInstructionStatus.choices, + default=VacationPayInstructionStatus.PENDING_AMOUNT, + ) + payroll_input_line = models.OneToOneField( + PayrollInputLine, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="vacation_pay_instruction", + verbose_name="Строка начисления", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_vacation_pay_instructions", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Поручение на отпускные" + verbose_name_plural = "Поручения на отпускные" + ordering = ["-created_at", "-id"] + + def clean(self): + if self.paid_days < 1: + raise ValidationError( + {"paid_days": "В финансы можно передать только оплачиваемый отпуск."} + ) + + def __str__(self): + return f"Отпускные по заявлению #{self.vacation_terms.request_id}" + + class PayrollInputCopyConfiguration(models.Model): """Per-operator defaults for copying input lines between payroll periods.""" diff --git a/backend/requests_app/admin.py b/backend/requests_app/admin.py index df9c7b73..d41d8e27 100644 --- a/backend/requests_app/admin.py +++ b/backend/requests_app/admin.py @@ -5,7 +5,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from .enums import RequestStatus -from .models import Request, RequestSettings +from .models import Request, RequestSettings, RequestVacationTerms @admin.register(RequestSettings) @@ -31,6 +31,25 @@ class RequestSettingsAdmin(admin.ModelAdmin): return count or "Все сотрудники" +@admin.register(RequestVacationTerms) +class RequestVacationTermsAdmin(admin.ModelAdmin): + list_display = ( + "request", + "paid_days", + "finance_status", + "recorded_by", + "updated_at", + ) + list_filter = ("finance_status",) + search_fields = ( + "request__employee__last_name", + "request__employee__first_name", + "request__employee__email", + ) + autocomplete_fields = ("request", "recorded_by") + readonly_fields = ("recorded_at", "updated_at") + + # Админ-форма: без принуждения вводить approver (мы выставим его автоматически) class RequestAdminForm(forms.ModelForm): class Meta: diff --git a/backend/requests_app/application/__init__.py b/backend/requests_app/application/__init__.py index f43150cd..d0c1118a 100644 --- a/backend/requests_app/application/__init__.py +++ b/backend/requests_app/application/__init__.py @@ -3,9 +3,17 @@ from .statistics import ( can_view_request_statistics, resolve_stats_window_from_query, ) +from .vacation import ( + can_manage_vacation_terms, + save_vacation_terms, + vacation_total_days, +) __all__ = [ "build_employee_statistics", + "can_manage_vacation_terms", "can_view_request_statistics", "resolve_stats_window_from_query", + "save_vacation_terms", + "vacation_total_days", ] diff --git a/backend/requests_app/application/statistics.py b/backend/requests_app/application/statistics.py index f513a6bd..12fef39b 100644 --- a/backend/requests_app/application/statistics.py +++ b/backend/requests_app/application/statistics.py @@ -28,6 +28,11 @@ def can_view_request_statistics(user) -> bool: def resolve_stats_window_from_query(period, query_params): + if period == "half_year": + today = timezone.localdate() + month_index = today.year * 12 + today.month - 1 - 5 + start_year, start_month_index = divmod(month_index, 12) + return date(start_year, start_month_index + 1, 1), today if period == "month": today = timezone.localdate() _, last_day = calendar.monthrange(today.year, today.month) @@ -73,12 +78,11 @@ def _duration_days_in_window(request_obj, start_date, end_date): if request_obj.date_from and request_obj.date_to: overlap_start = max(request_obj.date_from, start_date) overlap_end = min(request_obj.date_to, end_date) - return 0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1 - return int( - bool( - request_obj.date_from - and start_date <= request_obj.date_from <= end_date + return ( + 0 if overlap_start > overlap_end else (overlap_end - overlap_start).days + 1 ) + return int( + bool(request_obj.date_from and start_date <= request_obj.date_from <= end_date) ) @@ -92,6 +96,13 @@ def _is_unpaid_vacation(request_obj): return any(marker in text for marker in UNPAID_VACATION_MARKERS) +def _vacation_paid_days_in_window(request_obj, duration): + terms = getattr(request_obj, "vacation_terms", None) + if terms is None: + return 0 if _is_unpaid_vacation(request_obj) else duration + return min(terms.paid_days, duration) + + def build_employee_statistics( *, employee_id, @@ -117,30 +128,42 @@ def build_employee_statistics( "paid_vacation_days": 0, "unpaid_vacation_days": 0, } - approved_requests = EmployeeRequest.objects.filter( - employee_id=employee_id, - status=RequestStatus.APPROVED, - ).only("type", "date_from", "date_to", "time_from", "time_to", "title", "comment") + approved_requests = ( + EmployeeRequest.objects.filter( + employee_id=employee_id, + status=RequestStatus.APPROVED, + ) + .select_related("vacation_terms") + .only( + "type", + "date_from", + "date_to", + "time_from", + "time_to", + "title", + "comment", + "vacation_terms__paid_days", + ) + ) for request_obj in approved_requests: duration = _duration_days_in_window(request_obj, start_date, end_date) if request_obj.type == RequestType.SICK_LEAVE: totals["sick_leave_days"] += duration elif request_obj.type == RequestType.SHORT_DAY_OFF: - if request_obj.date_from and ( - start_date is None or start_date <= request_obj.date_from - ) and (end_date is None or request_obj.date_from <= end_date): + if ( + request_obj.date_from + and (start_date is None or start_date <= request_obj.date_from) + and (end_date is None or request_obj.date_from <= end_date) + ): totals["short_day_off_minutes"] += request_obj.duration_minutes or 0 elif request_obj.type == RequestType.DAY_OFF: totals["day_off_days"] += duration elif request_obj.type == RequestType.MATERNITY: totals["maternity_days"] += duration elif request_obj.type == RequestType.VACATION: - key = ( - "unpaid_vacation_days" - if _is_unpaid_vacation(request_obj) - else "paid_vacation_days" - ) - totals[key] += duration + paid_days = _vacation_paid_days_in_window(request_obj, duration) + totals["paid_vacation_days"] += paid_days + totals["unpaid_vacation_days"] += max(duration - paid_days, 0) return { "employee_id": employee_id, @@ -149,9 +172,17 @@ def build_employee_statistics( "date_from": start_date.isoformat() if start_date else None, "date_to": end_date.isoformat() if end_date else None, "total_submitted_requests": base_queryset.count(), - "sick_leave_requests_count": base_queryset.filter(type=RequestType.SICK_LEAVE).count(), - "day_off_requests_count": base_queryset.filter(type__in=DAY_OFF_REQUEST_TYPES).count(), - "short_day_off_requests_count": base_queryset.filter(type=RequestType.SHORT_DAY_OFF).count(), - "maternity_requests_count": base_queryset.filter(type=RequestType.MATERNITY).count(), + "sick_leave_requests_count": base_queryset.filter( + type=RequestType.SICK_LEAVE + ).count(), + "day_off_requests_count": base_queryset.filter( + type__in=DAY_OFF_REQUEST_TYPES + ).count(), + "short_day_off_requests_count": base_queryset.filter( + type=RequestType.SHORT_DAY_OFF + ).count(), + "maternity_requests_count": base_queryset.filter( + type=RequestType.MATERNITY + ).count(), **totals, } diff --git a/backend/requests_app/application/vacation.py b/backend/requests_app/application/vacation.py new file mode 100644 index 00000000..a69da1f7 --- /dev/null +++ b/backend/requests_app/application/vacation.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from rest_framework.exceptions import ValidationError + +from finance.enums import VacationPayInstructionStatus +from finance.models import PayrollPeriod, VacationPayInstruction +from requests_app.enums import ( + RequestStatus, + RequestType, + VacationFinanceStatus, +) +from requests_app.models import Request, RequestVacationTerms + + +def vacation_total_days(request_obj: Request) -> int: + if not (request_obj.date_from and request_obj.date_to): + return 0 + return max((request_obj.date_to - request_obj.date_from).days + 1, 0) + + +def can_manage_vacation_terms(user, request_obj: Request) -> bool: + return bool( + getattr(user, "is_authenticated", False) + and ( + request_obj.approver_id == user.id + or getattr(user, "is_superuser", False) + or user.has_perm("requests_app.can_process_requests") + ) + ) + + +def _matching_payroll_period(request_obj: Request): + if not request_obj.date_from: + return None + return ( + PayrollPeriod.objects.filter( + date_from__lte=request_obj.date_from, + date_to__gte=request_obj.date_from, + ) + .order_by("date_from", "id") + .first() + ) + + +def _validate_vacation_decision( + request_obj: Request, + *, + paid_days: int, + record_to_finance: bool, +) -> None: + if request_obj.type != RequestType.VACATION: + raise ValidationError({"request": "Это заявление не является отпуском."}) + if request_obj.status != RequestStatus.APPROVED: + raise ValidationError({"request": "Сначала одобрите заявление на отпуск."}) + + total_days = vacation_total_days(request_obj) + if paid_days < 0 or paid_days > total_days: + raise ValidationError( + {"paid_days": f"Укажите значение от 0 до {total_days} включительно."} + ) + if record_to_finance and paid_days == 0: + raise ValidationError( + {"record_to_finance": "Неоплачиваемый отпуск нечего передавать в финансы."} + ) + + +def _ensure_instruction_is_mutable( + existing_terms, + existing_instruction, + *, + paid_days, + record_to_finance, +) -> None: + if not existing_instruction: + return + instruction_is_posted = bool( + existing_instruction.payroll_input_line_id + or existing_instruction.status == VacationPayInstructionStatus.POSTED + ) + terms_are_changing = bool( + existing_terms.paid_days != paid_days or not record_to_finance + ) + if instruction_is_posted and terms_are_changing: + raise ValidationError( + { + "record_to_finance": ( + "Начисление уже создано. Измените или сторнируйте его в финансах." + ) + } + ) + + +@transaction.atomic +def save_vacation_terms( + *, + request_obj: Request, + actor, + paid_days: int, + record_to_finance: bool, +) -> RequestVacationTerms: + """Сохраняет решение по отпуску и идемпотентно обновляет поручение финансам.""" + + locked_request = Request.objects.select_for_update().get(pk=request_obj.pk) + _validate_vacation_decision( + locked_request, + paid_days=paid_days, + record_to_finance=record_to_finance, + ) + + existing_terms = ( + RequestVacationTerms.objects.select_for_update() + .filter(request=locked_request) + .first() + ) + existing_instruction = None + if existing_terms: + existing_instruction = ( + VacationPayInstruction.objects.select_for_update() + .filter(vacation_terms=existing_terms) + .first() + ) + _ensure_instruction_is_mutable( + existing_terms, + existing_instruction, + paid_days=paid_days, + record_to_finance=record_to_finance, + ) + + instruction_is_posted = bool( + existing_instruction + and ( + existing_instruction.payroll_input_line_id + or existing_instruction.status == VacationPayInstructionStatus.POSTED + ) + ) + finance_status = VacationFinanceStatus.SKIPPED + if record_to_finance: + finance_status = ( + VacationFinanceStatus.POSTED + if instruction_is_posted + else VacationFinanceStatus.PENDING + ) + terms, _ = RequestVacationTerms.objects.update_or_create( + request=locked_request, + defaults={ + "paid_days": paid_days, + "finance_status": finance_status, + "recorded_by": actor, + }, + ) + try: + terms.full_clean() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + terms.save( + update_fields=["paid_days", "finance_status", "recorded_by", "updated_at"] + ) + + if record_to_finance and not instruction_is_posted: + instruction, _ = VacationPayInstruction.objects.update_or_create( + vacation_terms=terms, + defaults={ + "period": _matching_payroll_period(locked_request), + "paid_days": paid_days, + "status": VacationPayInstructionStatus.PENDING_AMOUNT, + "created_by": ( + existing_instruction.created_by if existing_instruction else actor + ), + }, + ) + try: + instruction.full_clean() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + instruction.save() + elif existing_instruction and not existing_instruction.payroll_input_line_id: + existing_instruction.status = VacationPayInstructionStatus.VOIDED + existing_instruction.save(update_fields=["status", "updated_at"]) + + return terms diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py index be4a3a8f..43c25944 100644 --- a/backend/requests_app/enums.py +++ b/backend/requests_app/enums.py @@ -16,9 +16,7 @@ class RequestType(TextChoices): OTHER = "other", "Другое" -DAY_OFF_REQUEST_TYPES = frozenset( - {RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF} -) +DAY_OFF_REQUEST_TYPES = frozenset({RequestType.DAY_OFF, RequestType.SHORT_DAY_OFF}) class RequestStatus(TextChoices): @@ -31,6 +29,15 @@ class RequestStatus(TextChoices): CANCELLED = "cancelled", "Отменено" +class VacationFinanceStatus(TextChoices): + """Состояние передачи согласованных условий отпуска в финансы.""" + + NOT_DECIDED = "not_decided", "Не решено" + SKIPPED = "skipped", "Не передавать" + PENDING = "pending", "Ожидает расчёта" + POSTED = "posted", "Передано в начисления" + + FINAL_STATUS = { RequestStatus.APPROVED, RequestStatus.REJECTED, diff --git a/backend/requests_app/migrations/0016_requestvacationterms.py b/backend/requests_app/migrations/0016_requestvacationterms.py new file mode 100644 index 00000000..448f78e4 --- /dev/null +++ b/backend/requests_app/migrations/0016_requestvacationterms.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.4 on 2026-08-26 09:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests_app', '0015_add_request_recipient_settings'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RequestVacationTerms', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('paid_days', models.PositiveSmallIntegerField(default=0, verbose_name='Оплачиваемые дни')), + ('finance_status', models.CharField(choices=[('not_decided', 'Не решено'), ('skipped', 'Не передавать'), ('pending', 'Ожидает расчёта'), ('posted', 'Передано в начисления')], default='not_decided', max_length=16, verbose_name='Передача в финансы')), + ('recorded_at', models.DateTimeField(auto_now_add=True, verbose_name='Зафиксировано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('recorded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recorded_vacation_terms', to=settings.AUTH_USER_MODEL, verbose_name='Условия зафиксировал')), + ('request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vacation_terms', to='requests_app.request', verbose_name='Заявление')), + ], + options={ + 'verbose_name': 'Условия отпуска', + 'verbose_name_plural': 'Условия отпусков', + }, + ), + ] diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index e2f9733b..9e60fbc0 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -13,7 +13,7 @@ from .constants import ( SAFE_EXTENSIONS, SAFE_MIME_TYPES, ) -from .enums import FINAL_STATUS, RequestStatus, RequestType +from .enums import FINAL_STATUS, RequestStatus, RequestType, VacationFinanceStatus logger = logging.getLogger(__name__) @@ -404,3 +404,62 @@ class Request(models.Model): return ( f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})" ) + + +class RequestVacationTerms(models.Model): + """Согласованные условия оплаты отпуска по заявлению.""" + + request = models.OneToOneField( + Request, + on_delete=models.CASCADE, + related_name="vacation_terms", + verbose_name="Заявление", + ) + paid_days = models.PositiveSmallIntegerField( + "Оплачиваемые дни", + default=0, + ) + finance_status = models.CharField( + "Передача в финансы", + max_length=16, + choices=VacationFinanceStatus.choices, + default=VacationFinanceStatus.NOT_DECIDED, + ) + recorded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="recorded_vacation_terms", + verbose_name="Условия зафиксировал", + ) + recorded_at = models.DateTimeField("Зафиксировано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Условия отпуска" + verbose_name_plural = "Условия отпусков" + + @property + def total_days(self) -> int: + if not (self.request.date_from and self.request.date_to): + return 0 + return max((self.request.date_to - self.request.date_from).days + 1, 0) + + @property + def unpaid_days(self) -> int: + return max(self.total_days - self.paid_days, 0) + + def clean(self): + errors = {} + if self.request.type != RequestType.VACATION: + errors["request"] = "Условия оплаты доступны только для отпуска." + if self.request.status != RequestStatus.APPROVED: + errors["request"] = "Сначала одобрите заявление на отпуск." + if self.paid_days > self.total_days: + errors["paid_days"] = ( + "Оплачиваемых дней не может быть больше длительности отпуска." + ) + if errors: + raise ValidationError(errors) + + def __str__(self): + return f"Заявление #{self.request_id}: {self.paid_days} оплачиваемых дней" diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 84a5a6d3..489e9d57 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -12,7 +12,9 @@ from rest_framework.test import APIClient from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest -from requests_app.models import RequestSettings +from requests_app.models import RequestSettings, RequestVacationTerms +from finance.enums import VacationPayInstructionStatus +from finance.models import PayrollPeriod, VacationPayInstruction from tasks.models import ( Task, TaskBoard, @@ -224,8 +226,7 @@ def test_employee_profile_paginates_requests( assert second_payload["has_next"] is False assert len(second_payload["results"]) == 1 result_ids = { - item["id"] - for item in first_payload["results"] + second_payload["results"] + item["id"] for item in first_payload["results"] + second_payload["results"] } assert result_ids == {request.id for request in created} @@ -294,9 +295,7 @@ def test_statistics_custom_period_filters_request_counts_and_absence_days( ) matching.date_from = datetime(2026, 4, 5).date() matching.date_to = datetime(2026, 4, 12).date() - matching.save( - update_fields=["created_at", "date_from", "date_to", "comment"] - ) + matching.save(update_fields=["created_at", "date_from", "date_to", "comment"]) outside_count = make_request( employee=employee, @@ -356,9 +355,7 @@ def test_list_only_participants_see_requests( employee=foreign_user, type_=RequestType.SICK_LEAVE ) cc_request = make_request(employee=foreign_user, type_=RequestType.DAY_OFF) - hidden_request = make_request( - employee=foreign_user, type_=RequestType.OTHER - ) + hidden_request = make_request(employee=foreign_user, type_=RequestType.OTHER) recipient_request.recipients.add(regular_user, recipient) cc_request.cc_users.add(regular_user, cc_user) @@ -429,9 +426,7 @@ def test_list_returns_scope_counts_and_filters_decisions( decision_response = client.get(f"{API_BASE}?decision_required=true") assert decision_response.status_code == 200 - decision_ids = { - item["id"] for item in _results(decision_response.json()) - } + decision_ids = {item["id"] for item in _results(decision_response.json())} assert decision_ids == {decision_request.id} assert own_request.id not in decision_ids assert copied_request.id not in decision_ids @@ -733,9 +728,7 @@ def test_empty_recipient_settings_allow_all_active_employees( recipient = make_user(email="request-open-recipient@example.com") RequestSettings.get_solo() - options_resp = auth_client(regular_user).get( - f"{API_BASE}recipient-options/" - ) + options_resp = auth_client(regular_user).get(f"{API_BASE}recipient-options/") create_resp = auth_client(regular_user).post( API_BASE, data={ @@ -760,9 +753,7 @@ def test_hidden_employee_is_absent_from_request_recipients_and_cc( hidden.save(update_fields=["is_hidden_in_ui"]) RequestSettings.get_solo() - options_response = auth_client(regular_user).get( - f"{API_BASE}recipient-options/" - ) + options_response = auth_client(regular_user).get(f"{API_BASE}recipient-options/") create_response = auth_client(regular_user).post( API_BASE, data={ @@ -817,9 +808,7 @@ def test_configured_recipient_settings_restrict_primary_recipients( settings = RequestSettings.get_solo() settings.allowed_recipients.add(allowed) - options_resp = auth_client(regular_user).get( - f"{API_BASE}recipient-options/" - ) + options_resp = auth_client(regular_user).get(f"{API_BASE}recipient-options/") allowed_resp = auth_client(regular_user).post( API_BASE, data={ @@ -879,9 +868,7 @@ def test_inactive_configured_recipient_does_not_disable_restriction( settings = RequestSettings.get_solo() settings.allowed_recipients.add(inactive) - response = auth_client(regular_user).get( - f"{API_BASE}recipient-options/" - ) + response = auth_client(regular_user).get(f"{API_BASE}recipient-options/") assert response.status_code == 200 assert response.json()["restricted"] is True @@ -1101,8 +1088,7 @@ def test_create_request_sends_notification_to_cc_users( author_name = regular_user.get_full_name() or regular_user.username assert cc_notification.data["title"] == "Новое заявление" assert ( - f"{author_name} поставил вас в копию заявления" - in cc_notification.description + f"{author_name} поставил вас в копию заявления" in cc_notification.description ) @@ -1149,7 +1135,11 @@ def test_submit_requires_full_validation_even_for_existing_draft( def test_update_and_delete_require_author_even_for_admin( - auth_client, regular_user: models.Model, admin_user: models.Model, make_user, make_request + auth_client, + regular_user: models.Model, + admin_user: models.Model, + make_user, + make_request, ) -> None: """Редактирование и удаление через API доступны только автору.""" owner = make_user(email="update-owner@example.com") @@ -1189,11 +1179,14 @@ def test_update_and_delete_require_author_even_for_admin( own_final.recipients.add(own_recipient) own_client = auth_client(regular_user) - assert own_client.patch( - f"{API_BASE}{own_pending.id}/", - data={"comment": "обновлено"}, - format="json", - ).status_code == 200 + assert ( + own_client.patch( + f"{API_BASE}{own_pending.id}/", + data={"comment": "обновлено"}, + format="json", + ).status_code + == 200 + ) assert own_client.patch( f"{API_BASE}{own_final.id}/", data={"comment": "нельзя"}, @@ -1229,6 +1222,123 @@ def test_multiple_recipients_can_decide_until_request_becomes_final( assert second_resp.status_code in (400, 403) +def test_approving_vacation_requests_terms_and_can_save_finance_instruction( + auth_client, make_user, make_request +) -> None: + owner = make_user(email="vacation-terms-owner@example.com") + recipient = make_user(email="vacation-terms-approver@example.com") + vacation = make_request( + employee=owner, + type_=RequestType.VACATION, + status=RequestStatus.PENDING, + ) + vacation.date_from = date(2026, 8, 10) + vacation.date_to = date(2026, 8, 19) + vacation.save(update_fields=["date_from", "date_to"]) + vacation.recipients.add(recipient) + payroll_period = PayrollPeriod.objects.create( + code="2026-08-vacation-test", + name="Август 2026", + date_from=date(2026, 8, 1), + date_to=date(2026, 8, 31), + ) + + approve_response = auth_client(recipient).post( + f"{API_BASE}{vacation.id}/approve/", + data={}, + format="json", + ) + + assert approve_response.status_code == 200 + assert approve_response.json()["vacation_terms_required"] is True + assert approve_response.json()["can_manage_vacation_terms"] is True + + context_response = auth_client(recipient).get( + f"{API_BASE}{vacation.id}/vacation-terms/" + ) + assert context_response.status_code == 200 + assert context_response.json()["total_days"] == 10 + assert context_response.json()["statistics"]["period"] == "half_year" + + save_response = auth_client(recipient).put( + f"{API_BASE}{vacation.id}/vacation-terms/?period=half_year", + data={"paid_days": 7, "record_to_finance": True}, + format="json", + ) + + assert save_response.status_code == 200 + payload = save_response.json() + assert payload["request"]["vacation_terms_required"] is False + assert payload["terms"]["paid_days"] == 7 + assert payload["terms"]["unpaid_days"] == 3 + terms = RequestVacationTerms.objects.get(request=vacation) + instruction = VacationPayInstruction.objects.get(vacation_terms=terms) + assert instruction.period == payroll_period + assert instruction.paid_days == 7 + assert instruction.status == VacationPayInstructionStatus.PENDING_AMOUNT + + +def test_unpaid_vacation_can_skip_finance_and_updates_exact_statistics( + auth_client, make_user, make_request +) -> None: + owner = make_user(email="unpaid-vacation-owner@example.com") + recipient = make_user(email="unpaid-vacation-approver@example.com") + vacation = make_request( + employee=owner, + type_=RequestType.VACATION, + status=RequestStatus.PENDING, + comment="обычный отпуск без текстовых маркеров", + ) + vacation.date_from = date(2026, 8, 1) + vacation.date_to = date(2026, 8, 5) + vacation.save(update_fields=["date_from", "date_to"]) + vacation.recipients.add(recipient) + auth_client(recipient).post(f"{API_BASE}{vacation.id}/approve/") + + save_response = auth_client(recipient).put( + f"{API_BASE}{vacation.id}/vacation-terms/?period=all", + data={"paid_days": 0, "record_to_finance": False}, + format="json", + ) + + assert save_response.status_code == 200 + payload = save_response.json() + assert payload["terms"]["finance_status"] == "skipped" + assert payload["statistics"]["paid_vacation_days"] == 0 + assert payload["statistics"]["unpaid_vacation_days"] == 5 + assert VacationPayInstruction.objects.count() == 0 + + +def test_vacation_terms_reject_invalid_days_and_foreign_editor( + auth_client, make_user, make_request +) -> None: + owner = make_user(email="invalid-vacation-owner@example.com") + recipient = make_user(email="invalid-vacation-approver@example.com") + outsider = make_user(email="invalid-vacation-outsider@example.com") + vacation = make_request( + employee=owner, + type_=RequestType.VACATION, + status=RequestStatus.PENDING, + ) + vacation.date_from = date(2026, 8, 1) + vacation.date_to = date(2026, 8, 3) + vacation.save(update_fields=["date_from", "date_to"]) + vacation.recipients.add(recipient) + auth_client(recipient).post(f"{API_BASE}{vacation.id}/approve/") + + invalid_response = auth_client(recipient).put( + f"{API_BASE}{vacation.id}/vacation-terms/", + data={"paid_days": 4, "record_to_finance": True}, + format="json", + ) + foreign_response = auth_client(outsider).get( + f"{API_BASE}{vacation.id}/vacation-terms/" + ) + + assert invalid_response.status_code == 400 + assert foreign_response.status_code == 403 + + def test_only_direct_recipients_can_decide( auth_client, admin_user: models.Model, make_user, make_request ) -> None: @@ -1243,7 +1353,9 @@ def test_only_direct_recipients_can_decide( assert auth_client(owner).post(f"{API_BASE}{req.id}/approve/").status_code == 403 assert auth_client(cc_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403 assert auth_client(outsider).post(f"{API_BASE}{req.id}/approve/").status_code == 403 - assert auth_client(admin_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403 + assert ( + auth_client(admin_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403 + ) def test_admin_can_decide_only_if_explicitly_added_to_recipients( @@ -1303,12 +1415,10 @@ def test_draft_is_visible_only_to_author( req.cc_users.add(cc_user) owner_ids = { - item["id"] - for item in _results(auth_client(owner).get(API_BASE).json()) + item["id"] for item in _results(auth_client(owner).get(API_BASE).json()) } recipient_ids = { - item["id"] - for item in _results(auth_client(recipient).get(API_BASE).json()) + item["id"] for item in _results(auth_client(recipient).get(API_BASE).json()) } cc_ids = { item["id"] for item in _results(auth_client(cc_user).get(API_BASE).json()) @@ -1317,28 +1427,18 @@ def test_draft_is_visible_only_to_author( assert req.id in owner_ids assert req.id not in recipient_ids assert req.id not in cc_ids - assert ( - req.id - not in { - item["id"] - for item in _results( - auth_client(recipient).get( - f"{API_BASE}?addressed_to_me=true" - ).json() - ) - } - ) - assert ( - req.id - not in { - item["id"] - for item in _results( - auth_client(cc_user).get( - f"{API_BASE}?addressed_to_me=true" - ).json() - ) - } - ) + assert req.id not in { + item["id"] + for item in _results( + auth_client(recipient).get(f"{API_BASE}?addressed_to_me=true").json() + ) + } + assert req.id not in { + item["id"] + for item in _results( + auth_client(cc_user).get(f"{API_BASE}?addressed_to_me=true").json() + ) + } assert auth_client(owner).get(f"{API_BASE}{req.id}/").status_code == 200 assert auth_client(recipient).get(f"{API_BASE}{req.id}/").status_code == 403 @@ -1360,29 +1460,32 @@ def test_comments_and_decisions_are_forbidden_for_draft( req.cc_users.add(cc_user) assert auth_client(owner).get(f"{API_BASE}{req.id}/comments/").status_code == 403 - assert auth_client(owner).post( - f"{API_BASE}{req.id}/comments/", - data={"text": "owner draft comment"}, - format="json", - ).status_code == 403 assert ( - auth_client(recipient).post( - f"{API_BASE}{req.id}/approve/", data={}, format="json" - ).status_code + auth_client(owner) + .post( + f"{API_BASE}{req.id}/comments/", + data={"text": "owner draft comment"}, + format="json", + ) + .status_code == 403 ) assert ( - auth_client(recipient).post( - f"{API_BASE}{req.id}/reject/", data={}, format="json" - ).status_code + auth_client(recipient) + .post(f"{API_BASE}{req.id}/approve/", data={}, format="json") + .status_code + == 403 + ) + assert ( + auth_client(recipient) + .post(f"{API_BASE}{req.id}/reject/", data={}, format="json") + .status_code == 403 ) assert auth_client(cc_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403 -def test_author_can_submit_draft_explicitly_to_pending( - auth_client, make_user -) -> None: +def test_author_can_submit_draft_explicitly_to_pending(auth_client, make_user) -> None: """Черновик переходит в pending только по явному save_as=submit.""" owner = make_user(email="draft-submit-owner@example.com") recipient = make_user(email="draft-submit-recipient@example.com") @@ -1433,34 +1536,55 @@ def test_comments_available_to_participants_only( assert owner_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200 assert recipient_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200 assert cc_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200 - assert owner_client.post( - f"{API_BASE}{req.id}/comments/", - data={"text": "owner"}, - format="json", - ).status_code == 201 - assert recipient_client.post( - f"{API_BASE}{req.id}/comments/", - data={"text": "recipient"}, - format="json", - ).status_code == 201 - assert cc_client.post( - f"{API_BASE}{req.id}/comments/", - data={"text": "cc"}, - format="json", - ).status_code == 201 + assert ( + owner_client.post( + f"{API_BASE}{req.id}/comments/", + data={"text": "owner"}, + format="json", + ).status_code + == 201 + ) + assert ( + recipient_client.post( + f"{API_BASE}{req.id}/comments/", + data={"text": "recipient"}, + format="json", + ).status_code + == 201 + ) + assert ( + cc_client.post( + f"{API_BASE}{req.id}/comments/", + data={"text": "cc"}, + format="json", + ).status_code + == 201 + ) assert auth_client(outsider).get(f"{API_BASE}{req.id}/comments/").status_code == 403 - assert auth_client(outsider).post( - f"{API_BASE}{req.id}/comments/", - data={"text": "outsider"}, - format="json", - ).status_code == 403 - assert auth_client(admin_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403 - assert auth_client(admin_user).post( - f"{API_BASE}{req.id}/comments/", - data={"text": "admin"}, - format="json", - ).status_code == 403 + assert ( + auth_client(outsider) + .post( + f"{API_BASE}{req.id}/comments/", + data={"text": "outsider"}, + format="json", + ) + .status_code + == 403 + ) + assert ( + auth_client(admin_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403 + ) + assert ( + auth_client(admin_user) + .post( + f"{API_BASE}{req.id}/comments/", + data={"text": "admin"}, + format="json", + ) + .status_code + == 403 + ) def test_cc_user_receives_comment_notification( @@ -1582,12 +1706,12 @@ def test_filters_apply_within_participant_scope( assert item["type"] == expected_status else: assert item["status"] == expected_status - assert my_request.id in {item["id"] for item in _results(auth_client(regular_user).get(API_BASE).json())} + assert my_request.id in { + item["id"] for item in _results(auth_client(regular_user).get(API_BASE).json()) + } -def test_invalid_filter_does_not_crash( - auth_client, regular_user: models.Model -) -> None: +def test_invalid_filter_does_not_crash(auth_client, regular_user: models.Model) -> None: """Странные значения фильтров не приводят к 500.""" client = auth_client(regular_user) resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=") diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index bb7a7d42..0447b7d4 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -10,6 +10,7 @@ import { RequestListSection } from "@/components/requests/RequestListSection"; import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsPanel"; import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel"; import { RequestTaskLinks } from "@/components/requests/RequestTaskLinks"; +import { RequestVacationTermsModal } from "@/components/requests/RequestVacationTermsModal"; import { RequestWideListItem } from "@/components/requests/RequestWideListItem"; import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader"; import { useUser } from "@/contexts/UserContext"; @@ -237,6 +238,7 @@ function RequestsPageContent() { onPreviewAttachment={h.setAttachmentPreview} onReject={h.handleReject} onSetCommentDraft={h.setCommentDraft} + onVacationTerms={h.openVacationTerms} request={h.detailsRequest} /> ) : ( @@ -310,6 +312,7 @@ function RequestsPageContent() { onPreviewAttachment={h.setAttachmentPreview} onReject={h.handleReject} onSetCommentDraft={h.setCommentDraft} + onVacationTerms={h.openVacationTerms} request={desktopWideLayout ? null : h.detailsRequest} /> @@ -344,6 +347,11 @@ function RequestsPageContent() { preview={h.attachmentPreview} onClose={() => h.setAttachmentPreview(null)} /> + ); } diff --git a/frontend/src/components/requests/RequestDetailModal.tsx b/frontend/src/components/requests/RequestDetailModal.tsx index 416e0753..553b328d 100644 --- a/frontend/src/components/requests/RequestDetailModal.tsx +++ b/frontend/src/components/requests/RequestDetailModal.tsx @@ -12,7 +12,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { displayUserName, formatDate, formatDateTime } from "@/lib/shared"; import type { Request, RequestComment } from "@/types/api"; -import { Ban, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; +import { Ban, CalendarDays, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; import { getRequestActionState } from "./requestActions"; import { RequestUserBadge } from "./RequestUserBadge"; @@ -36,6 +36,7 @@ type RequestDetailModalProps = { onPreviewAttachment: (preview: RequestAttachmentPreview) => void; onReject: (requestId: number) => void | Promise; onSetCommentDraft: (requestId: number, value: string) => void; + onVacationTerms: (request: Request) => void; request: Request | null; }; @@ -59,6 +60,7 @@ export function RequestDetailModal({ onPreviewAttachment, onReject, onSetCommentDraft, + onVacationTerms, request, }: RequestDetailModalProps) { if (!request) return null; @@ -76,6 +78,11 @@ export function RequestDetailModal({ : ""; const commentCount = request.comments_count ?? comments.length; const linkedTasks = request.linked_tasks || []; + const canEditVacationTerms = Boolean( + String(request.type || request.request_type || "").toLowerCase() === "vacation" + && request.status === "approved" + && request.can_manage_vacation_terms, + ); return ( ) : null} - {(canProcess || canCancel || canEdit || canDelete) ? ( + {(canProcess || canCancel || canEdit || canDelete || canEditVacationTerms) ? (
+ {canEditVacationTerms ? ( + + ) : null} {canProcess ? ( <> + ) : null} {canProcess ? ( <> + +
+ )} + > + {loading ? ( +
+ Загрузка условий… +
+ ) : error && !context ? ( +
{error}
+ ) : context ? ( +
+
+
+ +
+

{employeeName}

+

{formatRequestPeriod(request)}

+
+ + {context.total_days} дн. + +
+
+ +
+

Оплата отпуска

+
+ {([ + ["full", "Оплачиваемый"], + ["partial", "Частично"], + ["unpaid", "Без оплаты"], + ] as const).map(([value, label]) => ( + + ))} +
+ {mode === "partial" ? ( + + ) : null} +

+ Оплачиваемых: {paidDays} · без оплаты: {Math.max(context.total_days - paidDays, 0)} +

+
+ +
+
+

Статистика сотрудника

+ { + setLoading(true); + setError(null); + setPeriod(value as RequestVacationStatsPeriod); + }} + ariaLabel="Период статистики отпусков" + variant="badge" + /> +
+
+ + + +
+
+ + + + {error ?
{error}
: null} +
+ ) : null} +
+ ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+

{value}

+

{label}

+
+ ); +} diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index 5019bffc..a12a97bc 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -169,6 +169,7 @@ export function useRequestsPage(_userId: number | null | undefined) { /* form */ const [createOpen, setCreateOpen] = useState(false); const [editingRequest, setEditingRequest] = useState(null); + const [vacationTermsRequest, setVacationTermsRequest] = useState(null); const [form, setForm] = useState(createEmptyForm); /* filters */ @@ -485,11 +486,25 @@ export function useRequestsPage(_userId: number | null | undefined) { }, [nextPage, loadingMore, buildRequestParams]); const handleApprove = async (id: number) => { - try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); await refreshRequests(); } + try { + setBusyKey(`approve-${id}`); + setActionError(null); + const updated = await apiClient.approveRequest(id) as Request; + applyUpdatedRequest(updated); + if (updated.type === "vacation" && updated.vacation_terms_required) { + setVacationTermsRequest(updated); + } + await refreshRequests(); + } catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); } finally { setBusyKey(null); } }; + const handleVacationTermsSaved = useCallback((updated: Request) => { + applyUpdatedRequest(updated); + void refreshRequests(); + }, [applyUpdatedRequest, refreshRequests]); + const handleReject = async (id: number) => { try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); await refreshRequests(); } catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); } @@ -662,6 +677,10 @@ export function useRequestsPage(_userId: number | null | undefined) { /* UI */ attachmentPreview, setAttachmentPreview, + vacationTermsRequest, + openVacationTerms: setVacationTermsRequest, + closeVacationTerms: () => setVacationTermsRequest(null), + handleVacationTermsSaved, detailsRequest, setDetailsRequest, loading, refreshing, loadingMore, error, actionError, actionSuccess, diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index 5d9e416c..a11cee66 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, User } from '@/types/api'; +import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, RequestVacationStatsPeriod, RequestVacationTermsContext, User } from '@/types/api'; import type { RequestFn } from './utils'; export function createRequestsApi(request: RequestFn) { @@ -42,6 +42,10 @@ export function createRequestsApi(request: RequestFn) { getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`), deleteRequestComment: (requestId: number, commentId: number): Promise => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }), approveRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/approve/`, { method: 'POST' }), + getRequestVacationTerms: (requestId: number, period: RequestVacationStatsPeriod = 'half_year'): Promise => + request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`), + saveRequestVacationTerms: (requestId: number, payload: { paid_days: number; record_to_finance: boolean }, period: RequestVacationStatsPeriod = 'half_year'): Promise => + request(`/api/v1/requests/${requestId}/vacation-terms/?period=${period}`, { method: 'PUT', body: JSON.stringify(payload) }), rejectRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/reject/`, { method: 'POST' }), cancelRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/cancel/`, { method: 'POST' }), addRequestComment: (requestId: number, text: string) => request(`/api/v1/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }), diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ab411625..a7119cf8 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1265,6 +1265,39 @@ export interface Request { decided_at?: string | null; created_at: string; updated_at: string; + vacation_terms?: RequestVacationTerms | null; + vacation_terms_required?: boolean; + can_manage_vacation_terms?: boolean; +} + +export interface VacationFinanceInstruction { + id: number; + status: "pending_amount" | "draft" | "posted" | "voided"; + paid_days: number; + amount: string | null; + period_id: number | null; + payroll_input_line_id: number | null; +} + +export interface RequestVacationTerms { + paid_days: number; + unpaid_days: number; + total_days: number; + finance_status: "not_decided" | "skipped" | "pending" | "posted"; + recorded_by: User; + recorded_at: string; + updated_at: string; + finance_instruction: VacationFinanceInstruction | null; +} + +export type RequestVacationStatsPeriod = "half_year" | "year" | "month" | "all"; + +export interface RequestVacationTermsContext { + request_id: number; + total_days: number; + terms: RequestVacationTerms | null; + statistics: RequestEmployeeStatistics; + request?: Request; } export interface RequestComment { @@ -1278,7 +1311,7 @@ export interface RequestComment { export interface RequestEmployeeStatistics { employee_id: number; employee_name: string; - period: "all" | "year" | "month" | "custom"; + period: "all" | "half_year" | "year" | "month" | "custom"; date_from: string | null; date_to: string | null; total_submitted_requests: number;