diff --git a/backend/api/v1/finance/admin/common.py b/backend/api/v1/finance/admin/common.py index 038ee351..9aa2c411 100644 --- a/backend/api/v1/finance/admin/common.py +++ b/backend/api/v1/finance/admin/common.py @@ -23,9 +23,7 @@ from finance.models import ( PayrollAuditEvent, PayrollPeriod, ) -from finance.payroll.components import ( - PAYROLL_COMPONENTS_BY_CODE, -) +from finance.payroll.components import get_payroll_component from finance.payroll.access import ( can_access_payroll_management, has_payroll_permission, @@ -146,15 +144,19 @@ def _payroll_history_action_label(action): return "Состояние записи изменено" -def _validate_payroll_table_column(column_id): +def _validate_payroll_table_column(column_id, *, period=None): if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS: return column_id prefix, separator, component_code = column_id.partition(":") + try: + component = get_payroll_component(component_code, period=period) + except ValueError: + component = None if ( prefix == "component" and separator and component_code not in SYSTEM_COMPONENT_CODES - and component_code in PAYROLL_COMPONENTS_BY_CODE + and component is not None ): return column_id raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."}) diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 29bb6caa..48db2cfb 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -15,7 +15,7 @@ from finance.models import ( PayrollInputCopyConfiguration, PayrollInputLine, ) -from finance.payroll.components import PAYROLL_COMPONENTS, PAYROLL_COMPONENTS_BY_CODE +from finance.payroll.components import get_payroll_components from ..admin_serializers import ( PayrollComponentSerializer, @@ -42,13 +42,20 @@ def _validate_input_copy_periods(target_period, source_period): ) -def _copyable_input_lines(source_period): +def _copyable_input_lines(source_period, *, target_period=None): + component_codes = { + component.code for component in get_payroll_components(source_period) + } + if target_period is not None: + component_codes &= { + component.code for component in get_payroll_components(target_period) + } return ( PayrollInputLine.objects.filter( period=source_period, employee__is_hidden_in_ui=False, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), - component_code__in=PAYROLL_COMPONENTS_BY_CODE, + component_code__in=component_codes, ) .exclude( source=InputSource.API, @@ -109,7 +116,9 @@ def _input_copy_configuration_payload(actor): def _input_copy_preview(target_period, source_period, *, actor): _validate_input_copy_periods(target_period, source_period) - lines = list(_copyable_input_lines(source_period)) + lines = list( + _copyable_input_lines(source_period, target_period=target_period) + ) component_rows = { component.code: { "component": component, @@ -117,7 +126,7 @@ def _input_copy_preview(target_period, source_period, *, actor): "employee_ids": set(), "amount_total": Decimal("0.00"), } - for component in PAYROLL_COMPONENTS + for component in get_payroll_components(target_period) } employee_rows = {} for line in lines: @@ -228,7 +237,10 @@ def _apply_input_copy(*, target_period, source_period, actor, data): "idempotent_replay": True, } lines = list( - _copyable_input_lines(source_period).filter( + _copyable_input_lines( + source_period, + target_period=target_period, + ).filter( component_code__in=data["component_codes"] ) ) diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 7f8430ce..a874333f 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -18,15 +18,17 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response from finance.models import ( + PayrollCustomComponent, PayrollInputCopyConfiguration, PayrollPeriod, ) from finance.payroll.components import ( - PAYROLL_COMPONENTS, + get_payroll_components, ) from ...admin_serializers import ( PayrollComponentSerializer, + PayrollCustomComponentWriteSerializer, PayrollEmployeeMiniSerializer, PayrollPeriodSerializer, PayrollPeriodWriteSerializer, @@ -39,6 +41,7 @@ from ..common import ( PayrollAdminAPIView, _audit_api_mutation, _conflict, + _optional_int, ) from ..input_copy import _apply_input_copy @@ -61,7 +64,54 @@ class PayrollAdminComponentListView(PayrollAdminAPIView): allowed_permissions = INPUT_VIEW_PERMISSIONS def get(self, request): - return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data) + period_id = _optional_int(request, "period_id") + period = ( + get_object_or_404(PayrollPeriod, pk=period_id) + if period_id is not None + else None + ) + return Response( + PayrollComponentSerializer( + get_payroll_components(period), + many=True, + ).data + ) + + @transaction.atomic + def post(self, request): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + serializer = PayrollCustomComponentWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + next_order = ( + PayrollCustomComponent.objects.order_by("-display_order") + .values_list("display_order", flat=True) + .first() + or 890 + ) + 10 + component = PayrollCustomComponent( + **serializer.validated_data, + display_order=next_order, + created_by=request.user, + ) + try: + component.full_clean() + component.save(force_insert=True) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + except IntegrityError as exc: + raise ValidationError( + {"non_field_errors": "Не удалось сохранить колонку."} + ) from exc + _audit_api_mutation( + actor=request.user, + action="payroll.component_created", + instance=component, + metadata={"kind": component.kind}, + ) + return Response( + PayrollComponentSerializer(component).data, + status=status.HTTP_201_CREATED, + ) @extend_schema_view( diff --git a/backend/api/v1/finance/admin/period_views/comments.py b/backend/api/v1/finance/admin/period_views/comments.py index c2171ebf..8cdcafe7 100644 --- a/backend/api/v1/finance/admin/period_views/comments.py +++ b/backend/api/v1/finance/admin/period_views/comments.py @@ -43,7 +43,10 @@ class PayrollCellCommentsView(PayrollAdminAPIView): def get_context(self, pk, employee_id, column_id): period = get_object_or_404(PayrollPeriod, pk=pk) employee = get_object_or_404(Employee, pk=employee_id) - return period, employee, _validate_payroll_table_column(column_id) + return period, employee, _validate_payroll_table_column( + column_id, + period=period, + ) def get(self, request, pk, employee_id, column_id): period, employee, column_id = self.get_context( @@ -94,7 +97,7 @@ class PayrollCellCommentDetailView(PayrollAdminAPIView): def delete(self, request, pk, employee_id, column_id, comment_id): period = get_object_or_404(PayrollPeriod, pk=pk) employee = get_object_or_404(Employee, pk=employee_id) - column_id = _validate_payroll_table_column(column_id) + column_id = _validate_payroll_table_column(column_id, period=period) thread = get_object_or_404( PayrollCellCommentThread, period=period, diff --git a/backend/api/v1/finance/admin/workspace.py b/backend/api/v1/finance/admin/workspace.py index 92f7095c..0caf00c7 100644 --- a/backend/api/v1/finance/admin/workspace.py +++ b/backend/api/v1/finance/admin/workspace.py @@ -13,7 +13,7 @@ from finance.payroll.access import ( has_payroll_permission, has_simple_admin_access, ) -from finance.payroll.components import PAYROLL_COMPONENTS +from finance.payroll.components import get_payroll_components from ...employees.serializers import EmployeeBriefSerializer from ..admin_serializers import ( @@ -119,7 +119,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): many=True, ).data, "components": PayrollComponentSerializer( - PAYROLL_COMPONENTS, + get_payroll_components(selected_period), many=True, ).data, "periods": PayrollPeriodSerializer(periods, many=True).data, diff --git a/backend/api/v1/finance/payroll_serializers/__init__.py b/backend/api/v1/finance/payroll_serializers/__init__.py index 33a255bf..96819a01 100644 --- a/backend/api/v1/finance/payroll_serializers/__init__.py +++ b/backend/api/v1/finance/payroll_serializers/__init__.py @@ -14,6 +14,7 @@ from .common import ( PayrollCellCommentSerializer, PayrollCellCommentWriteSerializer, PayrollComponentSerializer, + PayrollCustomComponentWriteSerializer, PayrollEmployeeMiniSerializer, PayrollUserRefSerializer, ) @@ -79,6 +80,7 @@ __all__ = [ "PayrollCellCommentWriteSerializer", "PayrollCompleteWorkflowSerializer", "PayrollComponentSerializer", + "PayrollCustomComponentWriteSerializer", "PayrollEmployeeMiniSerializer", "PayrollInputLineSerializer", "PayrollInputLineWriteSerializer", diff --git a/backend/api/v1/finance/payroll_serializers/commands.py b/backend/api/v1/finance/payroll_serializers/commands.py index 670829c1..f180905d 100644 --- a/backend/api/v1/finance/payroll_serializers/commands.py +++ b/backend/api/v1/finance/payroll_serializers/commands.py @@ -12,9 +12,7 @@ from decimal import Decimal from django.contrib.auth import get_user_model from rest_framework import serializers -from finance.payroll.components import ( - PAYROLL_COMPONENT_CODE_CHOICES, -) +from .common import PayrollComponentCodeField Employee = get_user_model() @@ -25,13 +23,11 @@ class ApprovalCommandSerializer(serializers.Serializer): class ClearComponentCellCommandSerializer(serializers.Serializer): employee_id = serializers.IntegerField(min_value=1) - component_code = serializers.ChoiceField( - choices=PAYROLL_COMPONENT_CODE_CHOICES, + component_code = PayrollComponentCodeField( required=False, ) - component_id = serializers.ChoiceField( + component_id = PayrollComponentCodeField( source="component_code", - choices=PAYROLL_COMPONENT_CODE_CHOICES, required=False, write_only=True, ) @@ -57,13 +53,13 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer): class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): source_period_id = serializers.IntegerField(min_value=1) component_codes = serializers.ListField( - child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), + child=PayrollComponentCodeField(), allow_empty=False, required=False, ) component_ids = serializers.ListField( source="component_codes", - child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), + child=PayrollComponentCodeField(), allow_empty=False, required=False, write_only=True, diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 99814ff9..5fcd7e48 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -15,7 +15,10 @@ from api.v1.employees.serializers import EmployeeBriefSerializer from finance.enums import ( ApprovalStatus, InputSource, + PayrollComponentPeriodScope, ) +from finance.models import PayrollCustomComponent, PayrollPeriod +from finance.payroll.components import get_payroll_component Employee = get_user_model() @@ -93,7 +96,7 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer): class PayrollComponentSerializer(serializers.Serializer): - id = serializers.CharField(read_only=True) + id = serializers.SerializerMethodField() code = serializers.CharField(read_only=True) name = serializers.CharField(read_only=True) kind = serializers.CharField(read_only=True) @@ -101,6 +104,60 @@ class PayrollComponentSerializer(serializers.Serializer): requires_reason = serializers.BooleanField(read_only=True) is_active = serializers.BooleanField(read_only=True) display_order = serializers.IntegerField(read_only=True) + period_scope = serializers.CharField(read_only=True) + scope_period_id = serializers.IntegerField(read_only=True, allow_null=True) + + def get_id(self, obj) -> str: + return obj.code + + +class PayrollComponentCodeField(serializers.CharField): + default_error_messages = { + "unknown": "Неизвестный вид зарплатной операции.", + } + + def to_internal_value(self, data): + value = super().to_internal_value(data) + try: + get_payroll_component(value) + except ValueError: + self.fail("unknown") + return value + + +class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): + name = serializers.CharField(max_length=80, trim_whitespace=True) + period_scope = serializers.ChoiceField( + choices=PayrollComponentPeriodScope.choices, + default=PayrollComponentPeriodScope.CURRENT_AND_FUTURE, + ) + period_id = serializers.PrimaryKeyRelatedField( + source="scope_period", + queryset=PayrollPeriod.objects.all(), + required=False, + write_only=True, + ) + + class Meta: + model = PayrollCustomComponent + fields = [ + "name", + "kind", + "requires_reason", + "period_scope", + "period_id", + ] + + def validate(self, attrs): + attrs = super().validate(attrs) + scope = attrs["period_scope"] + if scope == PayrollComponentPeriodScope.ALL: + attrs.pop("scope_period", None) + elif attrs.get("scope_period") is None: + raise serializers.ValidationError( + {"period_id": "Для выбранной области укажите расчётный период."} + ) + return attrs class DraftWriteSerializerMixin: diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index 02da1f25..ac369c4e 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -17,16 +17,14 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, ) -from finance.payroll.components import ( - PAYROLL_COMPONENT_CODE_CHOICES, -) - from .common import ( DraftWriteSerializerMixin, + PayrollComponentCodeField, PayrollComponentSerializer, PayrollEmployeeMiniSerializer, PayrollUserRefSerializer, ) +from finance.payroll.components import get_payroll_component from .periods import PayrollCarryoverPeriodSerializer Employee = get_user_model() @@ -106,13 +104,11 @@ class PayrollInputLineWriteSerializer( source="employee", queryset=Employee.objects.visible_in_ui(), ) - component_code = serializers.ChoiceField( - choices=PAYROLL_COMPONENT_CODE_CHOICES, + component_code = PayrollComponentCodeField( required=False, ) - component_id = serializers.ChoiceField( + component_id = PayrollComponentCodeField( source="component_code", - choices=PAYROLL_COMPONENT_CODE_CHOICES, required=False, write_only=True, ) @@ -137,6 +133,14 @@ class PayrollInputLineWriteSerializer( ] def validate(self, attrs): - if not attrs.get("component_code"): + component_code = attrs.get("component_code") + if not component_code: raise serializers.ValidationError({"component_code": "Обязательное поле."}) + period = attrs.get("period") or getattr(self.instance, "period", None) + try: + get_payroll_component(component_code, period=period) + except ValueError as exc: + raise serializers.ValidationError( + {"component_code": "Колонка недоступна в выбранном периоде."} + ) from exc return attrs 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..4d9edabe --- /dev/null +++ b/backend/api/v1/requests_app/request_serializers/vacation.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from decimal import Decimal, ROUND_HALF_UP + +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 + daily_rate = None + if instruction.amount is not None and instruction.paid_days: + daily_rate = (instruction.amount / Decimal(instruction.paid_days)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + return { + "id": instruction.id, + "status": instruction.status, + "paid_days": instruction.paid_days, + "amount": str(instruction.amount) + if instruction.amount is not None + else None, + "daily_rate": str(daily_rate) if daily_rate 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/api/v1/requests_app/viewsets/workflow.py b/backend/api/v1/requests_app/viewsets/workflow.py index a75c4fb8..ced1f485 100644 --- a/backend/api/v1/requests_app/viewsets/workflow.py +++ b/backend/api/v1/requests_app/viewsets/workflow.py @@ -3,11 +3,18 @@ from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response from requests_app.enums import RequestStatus +from requests_app.notifications.read_service import RequestNotificationReadService from ..serializers import RequestReadSerializer class RequestWorkflowMixin: + def _mark_decision_notifications_read(self, request, request_obj): + RequestNotificationReadService().mark_request_notifications_as_read( + user=request.user, + employee_request=request_obj, + ) + def _serialized_request(self, request_obj): return Response( RequestReadSerializer( @@ -31,6 +38,7 @@ class RequestWorkflowMixin: if request_obj.is_final: raise ValidationError("Заявка уже в финальном статусе.") request_obj.approve(by_user=request.user) + self._mark_decision_notifications_read(request, request_obj) return self._serialized_request(request_obj) @action(detail=True, methods=["post"]) @@ -48,6 +56,7 @@ class RequestWorkflowMixin: if request_obj.is_final: raise ValidationError("Заявка уже в финальном статусе.") request_obj.reject(by_user=request.user) + self._mark_decision_notifications_read(request, request_obj) return self._serialized_request(request_obj) @action(detail=True, methods=["post"]) 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..41eb8599 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", "Коррекция +" @@ -24,6 +31,12 @@ class PayrollComponentKind(models.TextChoices): PAYMENT = "payment", "Выплата" +class PayrollComponentPeriodScope(models.TextChoices): + CURRENT_ONLY = "current_only", "Только выбранный период" + CURRENT_AND_FUTURE = "current_and_future", "Выбранный и будущие периоды" + ALL = "all", "Все периоды" + + class PayrollPeriodStatus(models.TextChoices): OPEN = "open", "Открыт" CALCULATED = "calculated", "Рассчитан" 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/migrations/0027_payrollcustomcomponent.py b/backend/finance/migrations/0027_payrollcustomcomponent.py new file mode 100644 index 00000000..e3b3e0ee --- /dev/null +++ b/backend/finance/migrations/0027_payrollcustomcomponent.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.4 on 2026-08-26 10:37 + +import django.core.validators +import django.db.models.deletion +import finance.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0026_alter_employeepayrate_source_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PayrollCustomComponent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')), + ('name', models.CharField(max_length=80, unique=True, verbose_name='Название')), + ('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')), + ('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ], + options={ + 'verbose_name': 'Пользовательская зарплатная колонка', + 'verbose_name_plural': 'Пользовательские зарплатные колонки', + 'ordering': ['display_order', 'name', 'id'], + }, + ), + ] diff --git a/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py new file mode 100644 index 00000000..305652f4 --- /dev/null +++ b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.4 on 2026-08-26 11:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0027_payrollcustomcomponent'), + ] + + operations = [ + migrations.AddField( + model_name='payrollcustomcomponent', + name='period_scope', + field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'), + ), + migrations.AddField( + model_name='payrollcustomcomponent', + name='scope_period', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'), + ), + ] diff --git a/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py new file mode 100644 index 00000000..2fba19cb --- /dev/null +++ b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-08-26 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0028_payrollcustomcomponent_period_scope_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='payrollcustomcomponent', + name='name', + field=models.CharField(max_length=80, verbose_name='Название'), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 96fa5326..8121f03a 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -21,8 +21,10 @@ from .enums import ( PayrollCarryoverDirection, PayrollCarryoverStatus, PayrollComponentKind, + PayrollComponentPeriodScope, PayrollPeriodStatus, PayrollRunStatus, + VacationPayInstructionStatus, ) currency_validator = RegexValidator( @@ -41,6 +43,10 @@ payroll_cell_column_validator = RegexValidator( DEFAULT_DAILY_TARGET_POINTS = Decimal("5") +def generate_custom_payroll_component_code() -> str: + return f"CUSTOM_{uuid.uuid4().hex.upper()}" + + class FinanceSettings(models.Model): """Global access settings for the native finance management workspace.""" @@ -919,6 +925,132 @@ class PayrollComponent: objects = _PayrollComponentRegistryManager() +class PayrollCustomComponent(models.Model): + """User-created payroll column with immutable calculation semantics.""" + + code = models.CharField( + "Код", + max_length=64, + unique=True, + default=generate_custom_payroll_component_code, + editable=False, + validators=[component_code_validator], + ) + name = models.CharField("Название", max_length=80) + kind = models.CharField( + "Влияние на расчёт", + max_length=24, + choices=PayrollComponentKind.choices, + ) + requires_reason = models.BooleanField( + "Требовать основание", + default=False, + ) + is_active = models.BooleanField("Активна", default=True) + display_order = models.PositiveIntegerField("Порядок", default=900) + period_scope = models.CharField( + "Периоды отображения", + max_length=24, + choices=PayrollComponentPeriodScope.choices, + default=PayrollComponentPeriodScope.ALL, + ) + scope_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="scoped_custom_components", + verbose_name="Начальный период", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_components", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создана", auto_now_add=True) + + class Meta: + verbose_name = "Пользовательская зарплатная колонка" + verbose_name_plural = "Пользовательские зарплатные колонки" + ordering = ["display_order", "name", "id"] + + def __str__(self): + return self.name + + def clean(self): + super().clean() + self.name = self.name.strip() + if not self.name: + raise ValidationError({"name": "Укажите название колонки."}) + if self.period_scope == PayrollComponentPeriodScope.ALL: + self.scope_period = None + elif self.scope_period_id is None: + raise ValidationError( + {"scope_period": "Для выбранной области укажите расчётный период."} + ) + + if not self.is_active: + return + + from finance.payroll.components import PAYROLL_COMPONENTS + + normalized_name = self.name.casefold() + has_builtin_conflict = any( + component.name.casefold() == normalized_name + for component in PAYROLL_COMPONENTS + ) + custom_components = ( + PayrollCustomComponent.objects.filter(is_active=True) + .exclude(pk=self.pk) + .select_related("scope_period") + ) + has_custom_conflict = any( + component.name.casefold() == normalized_name + and self.period_scope_overlaps(component) + for component in custom_components + ) + if has_builtin_conflict or has_custom_conflict: + raise ValidationError( + { + "name": ( + "Колонка с таким названием уже существует " + "в одном из выбранных периодов." + ) + } + ) + + def period_scope_overlaps(self, other): + """Return whether two component columns can appear in the same period.""" + + if ( + self.period_scope == PayrollComponentPeriodScope.ALL + or other.period_scope == PayrollComponentPeriodScope.ALL + ): + return True + if self.scope_period_id is None or other.scope_period_id is None: + return False + if ( + self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY + and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY + ): + return self.scope_period_id == other.scope_period_id + if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY: + return self.scope_period.date_from >= other.scope_period.date_from + if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY: + return other.scope_period.date_from >= self.scope_period.date_from + return True + + def applies_to_period(self, period): + if self.period_scope == PayrollComponentPeriodScope.ALL: + return True + if period is None or self.scope_period_id is None: + return False + if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY: + return period.pk == self.scope_period_id + return period.date_from >= self.scope_period.date_from + + class PayrollInputLine(DraftVersionedModel): """Append-only bonus, correction, deduction or recorded payment input.""" @@ -1097,7 +1229,12 @@ class PayrollInputLine(DraftVersionedModel): } ) try: - component = self.component + from finance.payroll.components import get_payroll_component + + component = get_payroll_component( + self.component_code, + period=self.period if self.period_id else None, + ) except ValueError as exc: raise ValidationError({"component_code": str(exc)}) from exc if component.requires_reason and not self.reason: @@ -1112,6 +1249,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/finance/payroll/components.py b/backend/finance/payroll/components.py index 79859525..1e9c46f2 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from finance.enums import PayrollComponentKind +from finance.enums import PayrollComponentPeriodScope @dataclass(frozen=True, slots=True) @@ -25,6 +26,14 @@ class PayrollComponentDefinition: def is_active(self) -> bool: return True + @property + def period_scope(self) -> str: + return PayrollComponentPeriodScope.ALL + + @property + def scope_period_id(self) -> None: + return None + def get_kind_display(self) -> str: return PayrollComponentKind(self.kind).label @@ -92,11 +101,54 @@ class _PayrollComponentCollection(tuple): class PayrollComponent: """Compatibility facade over the code registry; this is not a database model.""" - objects = _PayrollComponentCollection(PAYROLL_COMPONENTS) + class _Manager: + def all(self): + return get_payroll_components() + + def get(self, **criteria): + return self.all().get(**criteria) + + def filter(self, **criteria): + return self.all().filter(**criteria) + + def exclude(self, **criteria): + return self.all().exclude(**criteria) + + def order_by(self, *fields): + return self.all().order_by(*fields) + + objects = _Manager() -def get_payroll_component(code: str) -> PayrollComponentDefinition: - try: - return PAYROLL_COMPONENTS_BY_CODE[code] - except KeyError as exc: - raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc +def get_payroll_components(period=None) -> _PayrollComponentCollection: + """Return built-ins plus active user-created component columns.""" + + from finance.models import PayrollCustomComponent + + custom = list( + PayrollCustomComponent.objects.filter(is_active=True) + .select_related("scope_period") + .order_by("display_order", "name", "id") + ) + if period is not None: + custom = [component for component in custom if component.applies_to_period(period)] + return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom)) + + +def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition: + component = PAYROLL_COMPONENTS_BY_CODE.get(code) + if component is not None: + return component + + from finance.models import PayrollCustomComponent + + component = ( + PayrollCustomComponent.objects.filter(code=code, is_active=True) + .select_related("scope_period") + .first() + ) + if component is not None and ( + period is None or component.applies_to_period(period) + ): + return component + raise ValueError(f"Неизвестный вид зарплатной операции: {code}") diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 4807efce..382c5cb1 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -29,7 +29,7 @@ from finance.models import ( PayrollWorkRecord, PayrollWorkSettings, ) -from finance.payroll.components import PAYROLL_COMPONENTS +from finance.payroll.components import get_payroll_components from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request from .balances import opening_balances @@ -371,7 +371,7 @@ def build_payroll_period_table(period: PayrollPeriod): work_records = _latest_work_by_employee(period, employee_ids) input_lines = _input_lines_by_employee(period, employee_ids) balances = opening_balances(period, employee_ids) - components = PAYROLL_COMPONENTS + components = get_payroll_components(period) component_definitions = { component.code: _component_definition( component.code, diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index a66aa19d..3fd7ac7e 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -24,6 +24,7 @@ from finance.enums import ( InputSource, PayrollPeriodStatus, PayrollRunStatus, + VacationPayInstructionStatus, ) from finance.models import ( EmployeePayRate, @@ -37,6 +38,7 @@ from finance.models import ( PayrollStatementLine, PayrollWorkSettings, PayrollWorkRecord, + VacationPayInstruction, ) from .adapter import build_core_request, employee_snapshot @@ -53,6 +55,7 @@ from .config import ( ruleset_not_effective_message, ruleset_period_details, ) +from .components import get_payroll_component from .exceptions import PayrollOperationError, PayrollPermissionDenied from .work_norm import calculate_period_target_points @@ -116,6 +119,22 @@ def _operation_error(code: str, message: str, **details): raise PayrollOperationError(code, message, details=details) +def _mark_linked_vacation_instruction_posted(line: PayrollInputLine) -> None: + instruction = ( + VacationPayInstruction.objects.select_for_update() + .select_related("vacation_terms") + .filter(payroll_input_line=line) + .first() + ) + if instruction is None: + return + instruction.status = VacationPayInstructionStatus.POSTED + instruction.save(update_fields=["status", "updated_at"]) + terms = instruction.vacation_terms + terms.finance_status = "posted" + terms.save(update_fields=["finance_status", "updated_at"]) + + def _resolve_rates( period: PayrollPeriod, employee_ids: list[int], @@ -1976,13 +1995,21 @@ def set_component_input_line_value( ) -> PayrollInputLine: """Replace the aggregate value of one editable spreadsheet component cell.""" + period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + try: + get_payroll_component(component_code, period=period) + except ValueError as exc: + raise PayrollOperationError( + "COMPONENT_NOT_AVAILABLE", + "Колонка недоступна в выбранном расчётном периоде.", + details={"component_code": component_code}, + ) from exc clear_component_input_lines( period_id, employee_id=employee_id, component_code=component_code, actor=actor, ) - period = PayrollPeriod.objects.select_for_update().get(pk=period_id) line = PayrollInputLine( period=period, employee_id=employee_id, @@ -2110,12 +2137,13 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: expected_lock_version=record.lock_version, ) for line in input_lines: - _approve_record( + approved_line = _approve_record( line, actor=actor, object_label="input_line", expected_lock_version=line.lock_version, ) + _mark_linked_vacation_instruction_posted(approved_line) summary = { "rates": len(rates), @@ -2213,18 +2241,18 @@ def approve_input_line( expected_lock_version: int, ) -> PayrollInputLine: _require_permission(actor, "finance.approve_payroll_inputs") - reference = PayrollInputLine.objects.only("period_id").get( - pk=line_id - ) + reference = PayrollInputLine.objects.only("period_id").get(pk=line_id) period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id) _ensure_period_accepts_approvals(period) record = PayrollInputLine.objects.select_for_update().get(pk=line_id) - return _approve_record( + approved_line = _approve_record( record, actor=actor, object_label="input_line", expected_lock_version=expected_lock_version, ) + _mark_linked_vacation_instruction_posted(approved_line) + return approved_line @transaction.atomic 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..f47c743b --- /dev/null +++ b/backend/requests_app/application/vacation.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from rest_framework.exceptions import ValidationError + +from finance.enums import ( + ApprovalStatus, + InputSource, + VacationPayInstructionStatus, +) +from finance.models import ( + EmployeePayRate, + PayrollInputLine, + PayrollPeriod, + VacationPayInstruction, +) +from finance.payroll.config import base_rate_code +from finance.payroll.work_norm import ( + count_period_workdays, + resolve_employee_schedule, +) +from requests_app.enums import ( + RequestStatus, + RequestType, + VacationFinanceStatus, +) +from requests_app.models import Request, RequestVacationTerms + + +MONEY_QUANTUM = Decimal("0.01") + + +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 _approved_rate_for_vacation(request_obj: Request) -> EmployeePayRate: + rate = ( + EmployeePayRate.objects.filter( + employee_id=request_obj.employee_id, + rate_code=base_rate_code(), + status=ApprovalStatus.APPROVED, + effective_from__lte=request_obj.date_from, + ) + .order_by("-effective_from", "-revision", "-id") + .first() + ) + if rate is None: + raise ValidationError( + { + "record_to_finance": ( + "Для сотрудника нет утверждённой ставки на дату начала отпуска." + ) + } + ) + return rate + + +def _calculate_vacation_pay( + request_obj: Request, + *, + paid_days: int, +) -> tuple[PayrollPeriod, Decimal, Decimal]: + period = _matching_payroll_period(request_obj) + if period is None: + raise ValidationError( + { + "record_to_finance": ( + "Для даты начала отпуска не найден расчётный период." + ) + } + ) + rate = _approved_rate_for_vacation(request_obj) + if rate.currency != period.currency: + raise ValidationError( + { + "record_to_finance": ( + "Валюта ставки сотрудника не совпадает с валютой расчётного периода." + ) + } + ) + schedule, _ = resolve_employee_schedule(request_obj.employee) + workdays = count_period_workdays(period, schedule) + if workdays < 1: + raise ValidationError( + {"record_to_finance": "В расчётном периоде нет плановых рабочих дней."} + ) + daily_rate = (rate.amount / Decimal(workdays)).quantize( + MONEY_QUANTUM, + rounding=ROUND_HALF_UP, + ) + amount = (daily_rate * paid_days).quantize( + MONEY_QUANTUM, + rounding=ROUND_HALF_UP, + ) + return period, daily_rate, amount + + +def _vacation_line_reason( + request_obj: Request, + *, + paid_days: int, + daily_rate: Decimal, +) -> str: + return ( + f"Отпуск по заявлению #{request_obj.pk}: {paid_days} дн. × {daily_rate:.2f} ₽" + ) + + +def _upsert_payroll_input_line( + *, + request_obj: Request, + instruction: VacationPayInstruction, + actor, + period: PayrollPeriod, + paid_days: int, + daily_rate: Decimal, + amount: Decimal, +) -> PayrollInputLine: + line = instruction.payroll_input_line + if line is None: + line = PayrollInputLine( + created_by=actor, + status=ApprovalStatus.DRAFT, + source=InputSource.API, + source_ref=f"vacation-request:{request_obj.pk}", + ) + line.period = period + line.employee = request_obj.employee + line.component_code = "VACATION_PAY" + line.amount = amount + line.reason = _vacation_line_reason( + request_obj, + paid_days=paid_days, + daily_rate=daily_rate, + ) + try: + line.full_clean() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + line.save() + return line + + +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 + line = existing_instruction.payroll_input_line + instruction_is_posted = bool( + (line is not None and line.status == ApprovalStatus.APPROVED) + 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 + and existing_instruction.payroll_input_line.status + == ApprovalStatus.APPROVED + ) + 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: + period, daily_rate, amount = _calculate_vacation_pay( + locked_request, + paid_days=paid_days, + ) + instruction, _ = VacationPayInstruction.objects.update_or_create( + vacation_terms=terms, + defaults={ + "period": period, + "paid_days": paid_days, + "amount": amount, + "status": VacationPayInstructionStatus.DRAFT, + "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() + line = _upsert_payroll_input_line( + request_obj=locked_request, + instruction=instruction, + actor=actor, + period=period, + paid_days=paid_days, + daily_rate=daily_rate, + amount=amount, + ) + instruction.payroll_input_line = line + instruction.save( + update_fields=["payroll_input_line", "status", "amount", "updated_at"] + ) + elif existing_instruction and not instruction_is_posted: + if existing_instruction.payroll_input_line_id: + existing_instruction.payroll_input_line.delete() + existing_instruction.payroll_input_line = None + existing_instruction.status = VacationPayInstructionStatus.VOIDED + existing_instruction.save( + update_fields=["payroll_input_line", "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..8b010516 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date, datetime, time +from decimal import Decimal from typing import Any import pytest @@ -12,7 +13,14 @@ 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 ApprovalStatus, VacationPayInstructionStatus +from finance.models import ( + EmployeePayRate, + PayrollInputLine, + PayrollPeriod, + VacationPayInstruction, +) from tasks.models import ( Task, TaskBoard, @@ -100,6 +108,55 @@ def test_marks_only_opened_request_notifications_read( assert unrelated_notification.unread is True +@pytest.mark.parametrize("decision", ["approve", "reject"]) +def test_decision_marks_only_actors_request_notifications_read( + auth_client, + make_user, + make_request, + decision: str, +) -> None: + """Решение читает уведомления по заявлению только у принявшего его сотрудника.""" + owner = make_user(email=f"decision-{decision}-owner@example.com") + actor = make_user(email=f"decision-{decision}-actor@example.com") + other_recipient = make_user(email=f"decision-{decision}-other@example.com") + employee_request = make_request( + employee=owner, + status=RequestStatus.PENDING, + ) + employee_request.recipients.add(actor, other_recipient) + + actor_notification = Notification.objects.create( + recipient=actor, + verb="request_new", + data={"request_id": employee_request.id}, + ) + other_recipient_notification = Notification.objects.create( + recipient=other_recipient, + verb="request_new", + data={"request_id": employee_request.id}, + ) + unrelated_notification = Notification.objects.create( + recipient=actor, + verb="request_new", + data={"request_id": employee_request.id + 1}, + ) + + response = auth_client(actor).post( + f"{API_BASE}{employee_request.id}/{decision}/", + data={}, + format="json", + ) + + assert response.status_code == 200 + actor_notification.refresh_from_db() + other_recipient_notification.refresh_from_db() + unrelated_notification.refresh_from_db() + assert actor_notification.unread is False + assert actor_notification.timestamp_read is not None + assert other_recipient_notification.unread is True + assert unrelated_notification.unread is True + + def test_statistics_requires_privileged_access( auth_client, regular_user: models.Model, make_user ) -> None: @@ -224,8 +281,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 +350,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 +410,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 +481,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 +783,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 +808,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 +863,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 +923,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 +1143,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 +1190,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 +1234,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 +1277,143 @@ 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), + ) + EmployeePayRate.objects.create( + employee=owner, + rate_code="BASE", + amount=Decimal("80000.0000"), + currency="RUB", + effective_from=date(2026, 7, 1), + status=ApprovalStatus.APPROVED, + created_by=owner, + approved_by=recipient, + approved_at=timezone.now(), + ) + + 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.amount == Decimal("26666.64") + assert instruction.status == VacationPayInstructionStatus.DRAFT + line = PayrollInputLine.objects.get(pk=instruction.payroll_input_line_id) + assert line.period == payroll_period + assert line.employee == owner + assert line.component_code == "VACATION_PAY" + assert line.amount == Decimal("26666.64") + assert line.status == ApprovalStatus.DRAFT + assert line.source_ref == f"vacation-request:{vacation.pk}" + assert "7 дн. × 3809.52 ₽" in line.reason + + +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 +1428,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 +1490,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 +1502,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 +1535,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 +1611,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 +1781,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/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index b5dbb240..5eb9be45 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -23,6 +23,7 @@ from finance.models import ( PayrollAuditEvent, PayrollCellCommentThread, PayrollComponent, + PayrollCustomComponent, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, @@ -98,6 +99,236 @@ def make_approved_period_inputs( ) +def test_manager_can_add_custom_component_column( + user_factory, + auth_client_factory, +): + manager = user_factory(email="component.manager@example.test") + employee = user_factory(email="component.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + client = auth_client_factory(manager) + + created = client.post( + api_url("admin-component-list"), + { + "name": "Компенсация питания", + "kind": "earning", + "requires_reason": True, + "period_scope": "current_and_future", + "period_id": period.pk, + }, + format="json", + ) + + assert created.status_code == 201, created.content + component = created.json() + assert component["id"] == component["code"] + assert component["code"].startswith("CUSTOM_") + assert component["name"] == "Компенсация питания" + assert component["kind"] == "earning" + assert component["requires_reason"] is True + assert component["period_scope"] == "current_and_future" + assert component["scope_period_id"] == period.pk + assert PayrollCustomComponent.objects.filter(code=component["code"]).exists() + + saved = client.post( + api_url("admin-component-cell-set", pk=period.pk), + { + "employee_id": employee.pk, + "component_code": component["code"], + "amount": "1250.00", + }, + format="json", + ) + assert saved.status_code == 201, saved.content + line = PayrollInputLine.objects.get(pk=saved.json()["id"]) + assert line.component.name == "Компенсация питания" + assert line.reason == "Перезаписано в итоговой таблице" + + table = client.get(api_url("admin-period-table", pk=period.pk)) + assert table.status_code == 200, table.content + assert component["code"] in { + item["code"] for item in table.json()["component_columns"] + } + row = next( + item for item in table.json()["rows"] if item["employee"]["id"] == employee.pk + ) + assert row["component_amounts"] == {component["code"]: "1250.00"} + assert row["gross_before_adjustments"] == "1250.00" + assert row["gross_total"] == "1250.00" + assert row["net_pay"] == "1250.00" + assert row["payable"] == "1250.00" + + +@pytest.mark.parametrize( + ("period_scope", "expected_visibility"), + [ + ("current_only", (False, True, False)), + ("current_and_future", (False, True, True)), + ("all", (True, True, True)), + ], +) +def test_custom_component_period_scope_controls_table_visibility( + user_factory, + auth_client_factory, + period_scope, + expected_visibility, +): + manager = user_factory(email=f"scope.{period_scope}@example.test") + employee = user_factory(email=f"scope.employee.{period_scope}@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + periods = [ + PayrollPeriod.objects.create( + code="2026-05", + name="Май 2026", + date_from="2026-05-01", + date_to="2026-05-31", + pay_date="2026-06-05", + currency="RUB", + created_by=manager, + ), + make_period(creator=manager), + PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ), + ] + client = auth_client_factory(manager) + created = client.post( + api_url("admin-component-list"), + { + "name": f"Колонка {period_scope}", + "kind": "payment", + "requires_reason": False, + "period_scope": period_scope, + "period_id": periods[1].pk, + }, + format="json", + ) + assert created.status_code == 201, created.content + component_code = created.json()["code"] + + visibility = [] + for period in periods: + response = client.get(api_url("admin-period-table", pk=period.pk)) + assert response.status_code == 200, response.content + visibility.append( + component_code + in {item["code"] for item in response.json()["component_columns"]} + ) + assert tuple(visibility) == expected_visibility + + unavailable_period = next( + ( + period + for period, visible in zip(periods, expected_visibility, strict=True) + if not visible + ), + None, + ) + if unavailable_period is not None: + rejected = client.post( + api_url("admin-component-cell-set", pk=unavailable_period.pk), + { + "employee_id": employee.pk, + "component_code": component_code, + "amount": "500.00", + }, + format="json", + ) + assert rejected.status_code == 409, rejected.content + assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE" + assert not PayrollInputLine.objects.filter( + period=unavailable_period, + employee=employee, + component_code=component_code, + ).exists() + + +def test_custom_component_name_is_unique_only_in_overlapping_periods( + user_factory, + auth_client_factory, +): + manager = user_factory(email="scope.name.manager@example.test") + grant(manager, "manage_payroll_inputs") + periods = [ + PayrollPeriod.objects.create( + code="2026-05", + name="Май 2026", + date_from="2026-05-01", + date_to="2026-05-31", + pay_date="2026-06-05", + currency="RUB", + created_by=manager, + ), + make_period(creator=manager), + PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ), + ] + client = auth_client_factory(manager) + + def create_component(*, period, period_scope, name="Компенсация"): + return client.post( + api_url("admin-component-list"), + { + "name": name, + "kind": "earning", + "requires_reason": False, + "period_scope": period_scope, + "period_id": period.pk, + }, + format="json", + ) + + may_only = create_component( + period=periods[0], + period_scope="current_only", + ) + june_only = create_component( + period=periods[1], + period_scope="current_only", + name="КОМПЕНСАЦИЯ", + ) + from_july = create_component( + period=periods[2], + period_scope="current_and_future", + ) + + assert may_only.status_code == 201, may_only.content + assert june_only.status_code == 201, june_only.content + assert from_july.status_code == 201, from_july.content + + overlapping_requests = [ + create_component(period=periods[2], period_scope="current_only"), + create_component(period=periods[1], period_scope="current_and_future"), + create_component(period=periods[0], period_scope="all"), + ] + for response in overlapping_requests: + assert response.status_code == 400, response.content + assert "выбранных периодов" in response.json()["name"][0] + + builtin_conflict = create_component( + period=periods[0], + period_scope="current_only", + name="ПРЕМИЯ", + ) + assert builtin_conflict.status_code == 400, builtin_conflict.content + assert "выбранных периодов" in builtin_conflict.json()["name"][0] + + def test_copy_input_lines_previews_scope_and_handles_existing_values( user_factory, auth_client_factory, 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/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index e2087f07..9af80f4b 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -72,6 +72,7 @@ import type { PayrollBulkPointRateResult, PayrollBulkTargetPointsResult, PayrollCalculationBlocker, + PayrollCustomComponentWrite, PayrollInputLineWrite, PayrollInputCopyResult, PayrollInputHistoryEvent, @@ -902,6 +903,19 @@ export function PayrollAdminWorkspace({ } }; + const addComponentColumn = async (payload: PayrollCustomComponentWrite) => { + if (!canManageSourceData || !selectedPeriod) return; + await apiClient.createPayrollAdminComponent({ + ...payload, + period_id: selectedPeriod.id, + }); + await Promise.all([ + loadWorkspace(selectedPeriod.id, true), + refreshInlineTable("inputs"), + ]); + setNotice(`Колонка «${payload.name}» добавлена.`); + }; + const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => { if (!canManageSourceData || !selectedPeriod) return; const rawValue = value.trim().replace(",", "."); @@ -1254,7 +1268,7 @@ export function PayrollAdminWorkspace({ {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
- setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} /> + setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
) : null} {tab === "approval" ? ( diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index 853f50e6..a33f97d4 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -16,8 +16,8 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react"; -import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, Plus, RotateCcw, Search, Table2, X } from "lucide-react"; +import { Fragment, type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal"; @@ -27,6 +27,9 @@ import { } from "@/components/finance/admin/PayrollCellCommentsModal"; import { useMiddleMousePan } from "@/hooks/useMiddleMousePan"; import type { + PayrollComponentPeriodScope, + PayrollCustomComponentWrite, + PayrollLineKind, PayrollPeriodTable, PayrollPeriodTableComponent, PayrollPeriodTableRow, @@ -52,6 +55,44 @@ const kindLabels = { payment: "Выплата", }; +const kindHelpItems: Array<{ + kind: PayrollLineKind; + description: string; + formula: string; +}> = [ + { + kind: "earning", + description: "Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.", + formula: "Увеличивает «Начислено» и «К выплате».", + }, + { + kind: "adjustment_credit", + description: "Доплата, которая исправляет или уточняет уже рассчитанные начисления.", + formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».", + }, + { + kind: "adjustment_debit", + description: "Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.", + formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».", + }, + { + kind: "deduction", + description: "Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.", + formula: "Уменьшает «После удержаний» и «К выплате».", + }, + { + kind: "payment", + description: "Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.", + formula: "Уменьшает только остаток «К выплате», не меняя начисления и удержания.", + }, +]; + +const periodScopeLabels: Record = { + current_only: "Только текущий период", + current_and_future: "Текущий и будущие периоды", + all: "Все периоды", +}; + const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]"; const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums"; const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns"; @@ -415,6 +456,177 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp; ); } +function AddPayrollComponentModal({ + onCreate, + onClose, +}: { + onCreate: (payload: PayrollCustomComponentWrite) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(""); + const [kind, setKind] = useState("earning"); + const [requiresReason, setRequiresReason] = useState(false); + const [periodScope, setPeriodScope] = useState("current_and_future"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + const nameRef = useRef(null); + const helpOpenRef = useRef(false); + + useEffect(() => { + helpOpenRef.current = helpOpen; + }, [helpOpen]); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + nameRef.current?.focus(); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (helpOpenRef.current) { + setHelpOpen(false); + return; + } + if (!pending) onClose(); + }; + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("keydown", closeOnEscape); + previouslyFocused?.focus(); + }; + }, [onClose, pending]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const normalizedName = name.trim(); + if (!normalizedName) { + setError("Укажите название колонки."); + return; + } + setPending(true); + setError(null); + try { + await onCreate({ + name: normalizedName, + kind, + requires_reason: requiresReason, + period_scope: periodScope, + }); + onClose(); + } catch (createError) { + setError(getPayrollAdminError(createError, "Не удалось добавить колонку.")); + } finally { + setPending(false); + } + }; + + if (typeof document === "undefined") return null; + return createPortal( +
+ + +
+ + +
+ + + + + {error ?
{error}
: null} +
+ + +
+
+ + {helpOpen ? setHelpOpen(false)} /> : null} + , + document.body, + ); +} + +function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) { + const closeButtonRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + return () => previouslyFocused?.focus(); + }, []); + + return ( +
+ +
+ +
+ {kindHelpItems.map((item) => ( +
+

{kindLabels[item.kind]}

+

{item.description}

+

{item.formula}

+
+ ))} +
+ +
+ Итоговая формула: + К выплате = Начислено − Удержано − Выплачено. +
+ + + ); +} + function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record { const pointRecalculationLogic = pointPolicy === "proportional_with_excess" ? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный." @@ -834,6 +1046,7 @@ export function PayrollPeriodTableView({ onSaveRate, onSaveWork, onSaveComponent, + onAddComponent, onOpenCarryovers, onSetPaymentConfirmed, compact = false, @@ -851,6 +1064,7 @@ export function PayrollPeriodTableView({ onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise; onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise; onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise; + onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise; onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void; onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise; compact?: boolean; @@ -872,6 +1086,7 @@ export function PayrollPeriodTableView({ displayName: string; } | null>(null); const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); + const [addComponentOpen, setAddComponentOpen] = useState(false); const [rowsMenuOpen, setRowsMenuOpen] = useState(false); const [compactPanel, setCompactPanel] = useState("table"); const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState>(new Set()); @@ -1580,7 +1795,20 @@ export function PayrollPeriodTableView({ Сотрудник {groupSegments.map((segment, index) => ( 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}> - {segment.group} + + {segment.group} + {onAddComponent && segment.group === "Начисления и выплаты" && groupSegments.findIndex((item) => item.group === segment.group) === index ? ( + + ) : null} + ))} @@ -1660,6 +1888,7 @@ export function PayrollPeriodTableView({ : null} {activeHelp ? setActiveHelp(null)} /> : null} + {addComponentOpen && onAddComponent ? setAddComponentOpen(false)} /> : null} {breakdownEmployee ? ( 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/finance.ts b/frontend/src/lib/api/finance.ts index 180ce5c8..b98202e6 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -7,6 +7,11 @@ export type PayrollLineKind = | "deduction" | "payment"; +export type PayrollComponentPeriodScope = + | "current_only" + | "current_and_future" + | "all"; + export type PayrollPeriodSummary = { code: string; name: string; @@ -581,6 +586,15 @@ export type PayrollComponent = { requires_reason: boolean; is_active: boolean; display_order: number; + period_scope: PayrollComponentPeriodScope; + scope_period_id: number | null; +}; + +export type PayrollCustomComponentWrite = { + name: string; + kind: PayrollLineKind; + requires_reason: boolean; + period_scope: PayrollComponentPeriodScope; }; export type PayrollAdminPayRate = { @@ -1029,6 +1043,10 @@ export function createFinanceApi(request: RequestFn) { ), getPayrollAdminWorkspace: (periodId?: number): Promise => request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`), + createPayrollAdminComponent: ( + payload: PayrollCustomComponentWrite & { period_id: number }, + ): Promise => + request(`${adminPrefix}/components/`, jsonOptions("POST", payload)), getPayrollAdminPeriods: (): Promise> => request(`${adminPrefix}/periods/`), getPayrollAdminPeriodTable: (periodId: number): Promise => 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/lib/payroll-columns.test.js b/frontend/src/lib/payroll-columns.test.js index 803cf5df..4f1f9e66 100644 --- a/frontend/src/lib/payroll-columns.test.js +++ b/frontend/src/lib/payroll-columns.test.js @@ -6,13 +6,37 @@ import { normalizePayrollColumnOrder, } from "./payroll-columns.ts"; -test("payroll column order keeps saved columns and appends newly available ones", () => { +test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => { assert.deepEqual( normalizePayrollColumnOrder( ["payable", "actual_points", "removed", "payable"], ["rate_amount", "actual_points", "payable", "status"], ), - ["payable", "actual_points", "rate_amount", "status"], + ["payable", "rate_amount", "actual_points", "status"], + ); +}); + +test("new payroll component stays before the calculation group", () => { + assert.deepEqual( + normalizePayrollColumnOrder( + ["rate_amount", "component:BONUS", "gross_total", "payable", "status"], + [ + "rate_amount", + "component:BONUS", + "component:CUSTOM_ADVANCE", + "gross_total", + "payable", + "status", + ], + ), + [ + "rate_amount", + "component:BONUS", + "component:CUSTOM_ADVANCE", + "gross_total", + "payable", + "status", + ], ); }); diff --git a/frontend/src/lib/payroll-columns.ts b/frontend/src/lib/payroll-columns.ts index b6f6a084..6836d31f 100644 --- a/frontend/src/lib/payroll-columns.ts +++ b/frontend/src/lib/payroll-columns.ts @@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder( seen.add(columnId); return true; }); - for (const columnId of availableIds) { - if (!seen.has(columnId)) normalized.push(columnId); + for (const [availableIndex, columnId] of availableIds.entries()) { + if (seen.has(columnId)) continue; + + const nextKnownId = availableIds + .slice(availableIndex + 1) + .find((candidateId) => seen.has(candidateId)); + if (nextKnownId) { + normalized.splice(normalized.indexOf(nextKnownId), 0, columnId); + } else { + normalized.push(columnId); + } + seen.add(columnId); } return normalized; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ab411625..da2ad23c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1265,6 +1265,40 @@ 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; + daily_rate: 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 +1312,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;