diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 144219de..d906f29f 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -16,6 +16,10 @@ from finance.models import ( PayrollInputLine, ) from finance.payroll.components import get_payroll_components +from finance.payroll.scheduled_changes import ( + employee_is_eligible_for_scheduled_change, + is_generated_scheduled_change, +) from ..admin_serializers import ( PayrollComponentSerializer, @@ -86,6 +90,45 @@ def _employee_active_for_period(employee, period): return state.status != ACTION_DISMISSED +def _collapse_scheduled_effective_cells(lines): + """Fold a marker-adjusted source cell into one ordinary copied value.""" + + lines = [ + line + for line in lines + if not is_generated_scheduled_change(line) + or employee_is_eligible_for_scheduled_change(line.employee, line.period) + ] + grouped = {} + for line in lines: + grouped.setdefault((line.employee_id, line.component_code), []).append(line) + result = [] + for (employee_id, component_code), cell_lines in grouped.items(): + if not any(is_generated_scheduled_change(line) for line in cell_lines): + result.extend(cell_lines) + continue + amount = sum((line.signed_amount for line in cell_lines), Decimal("0")) + if amount <= 0: + continue + source_ids = ",".join(str(line.pk) for line in cell_lines) + effective = PayrollInputLine( + period=cell_lines[0].period, + employee=cell_lines[0].employee, + component_code=component_code, + amount=amount, + value_sign=1, + relates_to_period=None, + reason="Перенесено итоговое значение с применённым маркером", + include_in_carryover=any(line.include_in_carryover for line in cell_lines), + created_by=cell_lines[0].created_by, + ) + effective._copy_source_token = ( + f"effective-{employee_id}-{component_code}-{source_ids}" + ) + result.append(effective) + return result + + def _input_copy_configuration_payload(actor): configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first() if configuration is None: @@ -116,7 +159,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, target_period=target_period)) + lines = _collapse_scheduled_effective_cells( + list(_copyable_input_lines(source_period, target_period=target_period)) + ) component_rows = { component.code: { "component": component, @@ -240,6 +285,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): target_period=target_period, ).filter(component_code__in=data["component_codes"]) ) + lines = _collapse_scheduled_effective_cells(lines) if data["employee_scope"] == "selected": selected_ids = set(data["employee_ids"]) lines = [line for line in lines if line.employee_id in selected_ids] @@ -268,6 +314,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): line for line in possible_existing_lines if (line.employee_id, line.component_code) in selected_cells + and not is_generated_scheduled_change(line) and not ( line.source == InputSource.API and line.source_ref.startswith("payroll-carryover:") @@ -324,11 +371,14 @@ def _apply_input_copy(*, target_period, source_period, actor, data): employee=line.employee, component_code=line.component_code, amount=line.amount, + value_sign=line.value_sign, relates_to_period=line.relates_to_period, reason=line.reason, status=ApprovalStatus.DRAFT, source=InputSource.API, - source_ref=f"{copy_prefix}{line.pk}", + source_ref=( + f"{copy_prefix}{getattr(line, '_copy_source_token', line.pk)}" + ), include_in_carryover=line.include_in_carryover, created_by=actor, ) diff --git a/backend/api/v1/finance/admin/input_views/input_lines.py b/backend/api/v1/finance/admin/input_views/input_lines.py index 371e1a71..bb418969 100644 --- a/backend/api/v1/finance/admin/input_views/input_lines.py +++ b/backend/api/v1/finance/admin/input_views/input_lines.py @@ -19,6 +19,7 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, ) +from finance.payroll.scheduled_changes import SCHEDULED_CHANGE_SOURCE_PREFIX from finance.payroll.services import ( approve_input_line, clear_component_input_lines, @@ -67,6 +68,7 @@ class PayrollInputLineListCreateView(DraftCollectionView): "created_by", "approved_by", ) + .exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX) .prefetch_related("employee__departments_links__department") .order_by("-created_at", "-id") ) @@ -94,14 +96,18 @@ class PayrollInputLineDetailView(DraftDetailView): immutable_patch_fields = ("period_id", "employee_id", "component_code") def get_queryset(self): - return PayrollInputLine.objects.select_related( - "period", - "employee", - "employee__position", - "relates_to_period", - "created_by", - "approved_by", - ).prefetch_related("employee__departments_links__department") + return ( + PayrollInputLine.objects.select_related( + "period", + "employee", + "employee__position", + "relates_to_period", + "created_by", + "approved_by", + ) + .exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX) + .prefetch_related("employee__departments_links__department") + ) class PayrollComponentCellClearView(PayrollAdminAPIView): diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 1d2d1c3a..6d7d8904 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -18,7 +18,7 @@ from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from finance.enums import ApprovalStatus +from finance.enums import ApprovalStatus, PayrollComponentKind from finance.models import ( FinanceSettings, PayrollCustomComponent, @@ -135,12 +135,77 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): serializer.is_valid(raise_exception=True) values = dict(serializer.validated_data) scope_period = values.pop("scope_period", None) + scheduled_application_period = values.pop( + "scheduled_application_period", + None, + ) + if ( + not current.is_custom + and values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE + ): + raise ValidationError( + { + "kind": ( + "Запланированное изменение создаётся отдельной " + "пользовательской колонкой." + ) + } + ) + target_references = list( + PayrollCustomComponent.objects.filter( + is_active=True, + scheduled_target_component_code=code, + ).select_related("scheduled_application_period") + ) + target_would_be_unavailable = ( + values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE + ) + if values["period_scope"] == "current_only": + target_would_be_unavailable = target_would_be_unavailable or any( + reference.scheduled_application_period_id + != getattr(scope_period, "pk", None) + for reference in target_references + ) + elif values["period_scope"] == "current_and_future": + target_would_be_unavailable = target_would_be_unavailable or any( + scope_period is None + or reference.scheduled_application_period.date_from + < scope_period.date_from + for reference in target_references + ) + if target_references and target_would_be_unavailable: + _conflict( + "COMPONENT_HAS_SCHEDULED_CHANGES", + ( + "Колонка выбрана назначением запланированного изменения. " + "Новые настройки должны сохранять её в периоде применения." + ), + ) + scheduled_route_changed = ( + current.kind != values["kind"] + or getattr(current, "scheduled_target_component_code", "") + != values.get("scheduled_target_component_code", "") + or getattr(current, "scheduled_application_period_id", None) + != ( + scheduled_application_period.pk + if scheduled_application_period is not None + else None + ) + ) point_base_behavior_changed = current.include_in_point_base != values[ "include_in_point_base" ] or (values["include_in_point_base"] and current.kind != values["kind"]) existing_lines = PayrollInputLine.objects.filter(component_code=code).exclude( status=ApprovalStatus.VOIDED ) + if scheduled_route_changed and existing_lines.exists(): + _conflict( + "COMPONENT_SCHEDULE_HAS_INPUTS", + ( + "Нельзя изменить назначение или период маркера, пока в его " + "ячейках есть значения. Сначала очистите значения." + ), + ) period_scope = values["period_scope"] if period_scope == "current_only": existing_lines = existing_lines.exclude(period=scope_period) @@ -172,9 +237,16 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): for field_name, value in values.items(): setattr(custom, field_name, value) custom.scope_period = scope_period + custom.scheduled_application_period = scheduled_application_period try: custom.full_clean() - custom.save(update_fields=[*values, "scope_period"]) + custom.save( + update_fields=[ + *values, + "scope_period", + "scheduled_application_period", + ] + ) except DjangoValidationError as exc: raise ValidationError(_validation_detail(exc)) from exc audit_instance = custom @@ -185,6 +257,11 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): overrides[code] = { **values, "scope_period_id": scope_period.pk if scope_period else None, + "scheduled_application_period_id": ( + scheduled_application_period.pk + if scheduled_application_period + else None + ), } settings.payroll_component_overrides = overrides settings.save(update_fields=["payroll_component_overrides", "updated_at"]) @@ -260,6 +337,26 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): ) period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id")) mode = request.data.get("mode") + scheduled_references = PayrollCustomComponent.objects.filter( + is_active=True, + scheduled_target_component_code=code, + ) + if mode == "current": + scheduled_references = scheduled_references.filter( + scheduled_application_period=period + ) + elif mode == "current_and_future": + scheduled_references = scheduled_references.filter( + scheduled_application_period__date_from__gte=period.date_from + ) + if scheduled_references.exists(): + _conflict( + "COMPONENT_HAS_SCHEDULED_CHANGES", + ( + "Колонка выбрана назначением запланированного изменения. " + "Сначала измените или удалите связанные маркеры." + ), + ) affected_lines = PayrollInputLine.objects.filter(component_code=code).exclude( status=ApprovalStatus.VOIDED ) diff --git a/backend/api/v1/finance/payroll_serializers/commands.py b/backend/api/v1/finance/payroll_serializers/commands.py index fc7a13a9..5d70d447 100644 --- a/backend/api/v1/finance/payroll_serializers/commands.py +++ b/backend/api/v1/finance/payroll_serializers/commands.py @@ -7,8 +7,6 @@ by :mod:`finance.payroll.services`. from __future__ import annotations -from decimal import Decimal - from django.contrib.auth import get_user_model from rest_framework import serializers @@ -42,7 +40,6 @@ class SetComponentCellCommandSerializer(ClearComponentCellCommandSerializer): amount = serializers.DecimalField( max_digits=19, decimal_places=2, - min_value=Decimal("0.01"), ) diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 1447a085..ceba077e 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -15,6 +15,7 @@ from api.v1.employees.serializers import EmployeeBriefSerializer from finance.enums import ( ApprovalStatus, InputSource, + PayrollComponentKind, PayrollComponentPeriodScope, ) from finance.models import PayrollCustomComponent, PayrollPeriod @@ -111,6 +112,14 @@ class PayrollComponentSerializer(serializers.Serializer): visible_to_employee = serializers.BooleanField(read_only=True) is_custom = serializers.BooleanField(read_only=True) highlight_color = serializers.CharField(read_only=True, allow_blank=True) + scheduled_target_component_code = serializers.CharField( + read_only=True, + allow_blank=True, + ) + scheduled_application_period_id = serializers.IntegerField( + read_only=True, + allow_null=True, + ) def get_id(self, obj) -> str: return obj.code @@ -142,6 +151,12 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): required=False, write_only=True, ) + scheduled_application_period_id = serializers.PrimaryKeyRelatedField( + source="scheduled_application_period", + queryset=PayrollPeriod.objects.all(), + required=False, + allow_null=True, + ) class Meta: model = PayrollCustomComponent @@ -155,6 +170,8 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): "highlight_color", "period_scope", "period_id", + "scheduled_target_component_code", + "scheduled_application_period_id", ] def validate(self, attrs): @@ -166,6 +183,60 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): raise serializers.ValidationError( {"period_id": "Для выбранной области укажите расчётный период."} ) + if attrs["kind"] == PayrollComponentKind.SCHEDULED_CHANGE: + attrs["include_in_point_base"] = False + attrs["include_in_carryover"] = False + attrs["visible_to_employee"] = False + application_period = attrs.get("scheduled_application_period") + source_period = attrs.get("scope_period") + target_code = attrs.get("scheduled_target_component_code", "") + if application_period is None: + raise serializers.ValidationError( + {"scheduled_application_period_id": ("Выберите период применения.")} + ) + if ( + source_period + and application_period.date_from <= source_period.date_from + ): + raise serializers.ValidationError( + { + "scheduled_application_period_id": ( + "Период применения должен быть позже начального периода." + ) + } + ) + if application_period.status == "closed": + raise serializers.ValidationError( + { + "scheduled_application_period_id": ( + "Нельзя назначить изменение в закрытый период." + ) + } + ) + try: + target = get_payroll_component( + target_code, + period=application_period, + ) + except ValueError as exc: + raise serializers.ValidationError( + { + "scheduled_target_component_code": ( + "Колонка назначения недоступна в периоде применения." + ) + } + ) from exc + if target.kind == PayrollComponentKind.SCHEDULED_CHANGE: + raise serializers.ValidationError( + { + "scheduled_target_component_code": ( + "Запланированное изменение не может изменять другой маркер." + ) + } + ) + else: + attrs["scheduled_target_component_code"] = "" + attrs["scheduled_application_period"] = None return attrs diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index c4ffe5ae..3cff840e 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -12,6 +12,7 @@ from rest_framework import serializers from finance.enums import ( InputSource, + PayrollComponentKind, ) from finance.models import ( PayrollInputLine, @@ -84,6 +85,12 @@ class PayrollInputLineSerializer(serializers.ModelSerializer): "payroll-carryover:" ) + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.value_sign < 0: + data["amount"] = f"-{data['amount']}" + return data + def get_component(self, obj) -> dict: return PayrollComponentSerializer(obj.component).data @@ -139,9 +146,17 @@ class PayrollInputLineWriteSerializer( raise serializers.ValidationError({"component_code": "Обязательное поле."}) period = attrs.get("period") or getattr(self.instance, "period", None) try: - get_payroll_component(component_code, period=period) + component = get_payroll_component(component_code, period=period) except ValueError as exc: raise serializers.ValidationError( {"component_code": "Колонка недоступна в выбранном периоде."} ) from exc + if component.kind == PayrollComponentKind.SCHEDULED_CHANGE: + raise serializers.ValidationError( + { + "component_code": ( + "Запланированное изменение задаётся только в итоговой таблице." + ) + } + ) return attrs diff --git a/backend/finance/enums.py b/backend/finance/enums.py index 579228a0..26106858 100644 --- a/backend/finance/enums.py +++ b/backend/finance/enums.py @@ -29,6 +29,10 @@ class PayrollComponentKind(models.TextChoices): ADJUSTMENT_DEBIT = "adjustment_debit", "Отрицательная корректировка" DEDUCTION = "deduction", "Удержать" PAYMENT = "payment", "Уже выплачено" + SCHEDULED_CHANGE = ( + "scheduled_change", + "Изменить выбранную колонку в будущем периоде", + ) class PayrollComponentPeriodScope(models.TextChoices): diff --git a/backend/finance/migrations/0036_scheduled_component_changes.py b/backend/finance/migrations/0036_scheduled_component_changes.py new file mode 100644 index 00000000..a2a70c71 --- /dev/null +++ b/backend/finance/migrations/0036_scheduled_component_changes.py @@ -0,0 +1,96 @@ +# Generated by Django 5.2.4 on 2026-09-11 09:06 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("finance", "0035_payroll_component_settings"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="payrollcustomcomponent", + name="scheduled_application_period", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="scheduled_component_changes", + to="finance.payrollperiod", + verbose_name="Период применения", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="scheduled_target_component_code", + field=models.CharField( + blank=True, + help_text="Заполняется только для запланированного изменения.", + max_length=64, + validators=[ + django.core.validators.RegexValidator( + message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-", + regex="^[A-Z][A-Z0-9_.-]{0,63}$", + ) + ], + verbose_name="Колонка назначения", + ), + ), + migrations.AddField( + model_name="payrollinputline", + name="value_sign", + field=models.SmallIntegerField( + choices=[(1, "+"), (-1, "−")], default=1, verbose_name="Знак значения" + ), + ), + migrations.AlterField( + model_name="payrollcustomcomponent", + name="kind", + field=models.CharField( + choices=[ + ("earning", "Начислить сотруднику"), + ("adjustment_credit", "Положительная корректировка"), + ("adjustment_debit", "Отрицательная корректировка"), + ("deduction", "Удержать"), + ("payment", "Уже выплачено"), + ( + "scheduled_change", + "Изменить выбранную колонку в будущем периоде", + ), + ], + max_length=24, + verbose_name="Влияние на расчёт", + ), + ), + migrations.AlterField( + model_name="payrollstatementline", + name="kind", + field=models.CharField( + choices=[ + ("earning", "Начислить сотруднику"), + ("adjustment_credit", "Положительная корректировка"), + ("adjustment_debit", "Отрицательная корректировка"), + ("deduction", "Удержать"), + ("payment", "Уже выплачено"), + ( + "scheduled_change", + "Изменить выбранную колонку в будущем периоде", + ), + ], + max_length=24, + verbose_name="Тип", + ), + ), + migrations.AddConstraint( + model_name="payrollinputline", + constraint=models.CheckConstraint( + condition=models.Q(("value_sign__in", (-1, 1))), + name="pay_input_value_sign_valid", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index e6bce453..7927ec2b 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1024,6 +1024,21 @@ class PayrollCustomComponent(models.Model): ), default="", ) + scheduled_target_component_code = models.CharField( + "Колонка назначения", + max_length=64, + blank=True, + validators=[component_code_validator], + help_text="Заполняется только для запланированного изменения.", + ) + scheduled_application_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="scheduled_component_changes", + verbose_name="Период применения", + ) is_active = models.BooleanField("Активна", default=True) display_order = models.PositiveIntegerField("Порядок", default=900) period_scope = models.CharField( @@ -1086,6 +1101,84 @@ class PayrollCustomComponent(models.Model): {"scope_period": "Для выбранной области укажите расчётный период."} ) + is_scheduled = self.kind == PayrollComponentKind.SCHEDULED_CHANGE + if is_scheduled: + if self.period_scope != PayrollComponentPeriodScope.CURRENT_AND_FUTURE: + raise ValidationError( + { + "period_scope": ( + "Запланированное изменение действует от выбранного " + "периода до периода применения." + ) + } + ) + if not self.scheduled_target_component_code: + raise ValidationError( + {"scheduled_target_component_code": "Выберите колонку назначения."} + ) + if self.scheduled_application_period_id is None: + raise ValidationError( + {"scheduled_application_period": "Выберите период применения."} + ) + if ( + self.scope_period_id + and self.scheduled_application_period.date_from + <= self.scope_period.date_from + ): + raise ValidationError( + { + "scheduled_application_period": ( + "Период применения должен быть позже начального периода." + ) + } + ) + if self.scheduled_application_period.status == PayrollPeriodStatus.CLOSED: + raise ValidationError( + { + "scheduled_application_period": ( + "Нельзя назначить изменение в закрытый период." + ) + } + ) + try: + from finance.payroll.components import get_payroll_component + + target = get_payroll_component( + self.scheduled_target_component_code, + period=self.scheduled_application_period, + ) + except ValueError as exc: + raise ValidationError( + { + "scheduled_target_component_code": ( + "Колонка назначения недоступна в периоде применения." + ) + } + ) from exc + if target.kind == PayrollComponentKind.SCHEDULED_CHANGE: + raise ValidationError( + { + "scheduled_target_component_code": ( + "Запланированное изменение не может изменять другой маркер." + ) + } + ) + self.include_in_point_base = False + self.include_in_carryover = False + self.visible_to_employee = False + elif ( + self.scheduled_target_component_code + or self.scheduled_application_period_id is not None + ): + raise ValidationError( + { + "scheduled_target_component_code": ( + "Колонку назначения можно выбрать только для " + "запланированного изменения." + ) + } + ) + if not self.is_active: return @@ -1150,6 +1243,12 @@ class PayrollCustomComponent(models.Model): return True if period is None or self.scope_period_id is None: return False + if ( + self.kind == PayrollComponentKind.SCHEDULED_CHANGE + and self.scheduled_application_period_id + and period.date_from >= self.scheduled_application_period.date_from + ): + 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 @@ -1181,6 +1280,11 @@ class PayrollInputLine(DraftVersionedModel): decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))], ) + value_sign = models.SmallIntegerField( + "Знак значения", + choices=((1, "+"), (-1, "−")), + default=1, + ) relates_to_period = models.ForeignKey( PayrollPeriod, on_delete=models.CASCADE, @@ -1278,6 +1382,10 @@ class PayrollInputLine(DraftVersionedModel): condition=Q(amount__gt=0), name="pay_input_amount_positive", ), + models.CheckConstraint( + condition=Q(value_sign__in=(-1, 1)), + name="pay_input_value_sign_valid", + ), models.CheckConstraint( condition=Q(approved_by__isnull=True) | ~Q(created_by=F("approved_by")) @@ -1305,7 +1413,11 @@ class PayrollInputLine(DraftVersionedModel): ] def __str__(self): - return f"{self.employee}: {self.component_code} {self.amount}" + return f"{self.employee}: {self.component_code} {self.signed_amount}" + + @property + def signed_amount(self): + return self.amount * self.value_sign @property def component(self): @@ -1354,6 +1466,25 @@ class PayrollInputLine(DraftVersionedModel): raise ValidationError( {"reason": "Для перерасчёта прошлого периода укажите основание."} ) + if self.value_sign not in (-1, 1): + raise ValidationError({"value_sign": "Некорректный знак значения."}) + if ( + self.value_sign < 0 + and component.kind != PayrollComponentKind.SCHEDULED_CHANGE + ): + is_derived_value = self.source == InputSource.API and ( + self.source_ref.startswith("payroll-scheduled-change:") + or self.source_ref.startswith("payroll-period-copy:") + ) + if not is_derived_value: + raise ValidationError( + { + "value_sign": ( + "Отрицательный знак доступен только запланированным " + "изменениям." + ) + } + ) class VacationPayInstruction(models.Model): diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 1229c9d2..5d04d81a 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections import defaultdict from decimal import ROUND_HALF_UP, Context, Decimal, localcontext -from finance.enums import ApprovalStatus, InputSource +from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind from finance.models import ( EmployeePayRate, PayrollInputLine, @@ -23,6 +23,7 @@ from payroll_core import ( ) from .config import base_rate_code +from .scheduled_changes import effective_period_lines POINT_BASE_NEGATIVE_KINDS = frozenset( { @@ -66,8 +67,53 @@ def _mapped_input_lines( input_lines: list[PayrollInputLine], component_definitions, ) -> tuple[InputLine, ...]: + relevant_lines = [ + record + for record in input_lines + if component_definitions[record.component_code].kind + != PayrollComponentKind.SCHEDULED_CHANGE + ] + signed_codes = { + record.component_code for record in relevant_lines if record.value_sign < 0 + } mapped_lines = [] - for record in input_lines: + for component_code in signed_codes: + records = [ + record + for record in relevant_lines + if record.component_code == component_code + ] + component = component_definitions[component_code] + amount = sum((record.signed_amount for record in records), Decimal("0")) + if amount < 0: + raise ValueError( + f"Итоговое значение колонки {component.name} не может быть отрицательным." + ) + if amount == 0: + continue + mapped_lines.append( + InputLine( + line_id=( + f"effective:{component_code}:" + + ",".join(str(record.pk) for record in records) + ), + code=component.code, + label=component.name, + kind=LineKind(component.kind), + amount=amount, + source_ref="scheduled-effective-value", + reason="; ".join( + dict.fromkeys(record.reason for record in records if record.reason) + ), + include_in_carryover=( + component.include_in_carryover + and any(record.include_in_carryover for record in records) + ), + ) + ) + for record in relevant_lines: + if record.component_code in signed_codes: + continue component = component_definitions[record.component_code] source_period = None if record.relates_to_period_id: @@ -132,7 +178,9 @@ def _point_base_accrual( if not component.include_in_point_base: continue direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1 - point_base_lines += record.amount * direction + if component.kind == PayrollComponentKind.SCHEDULED_CHANGE: + continue + point_base_lines += record.signed_amount * direction return base_amount + point_base_lines @@ -205,6 +253,16 @@ def build_core_request( component_definitions=None, ) -> PayrollRequest: components_by_code = _component_map(period, component_definitions) + input_lines = effective_period_lines( + period, + rate.employee, + input_lines, + scheduled_codes={ + code + for code, component in components_by_code.items() + if component.kind == PayrollComponentKind.SCHEDULED_CHANGE + }, + ) target_points = work_record.target_points if work_record else None point_base_accrual = _point_base_accrual( rate, diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 31d88c0c..0b9e7de0 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -19,6 +19,8 @@ class PayrollComponentDefinition: scope_period_id: int | None = None is_custom: bool = False highlight_color: str = "" + scheduled_target_component_code: str = "" + scheduled_application_period_id: int | None = None @property def id(self) -> str: @@ -189,12 +191,24 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: scope_period_id=override.get( "scope_period_id", component.scope_period_id ), + scheduled_target_component_code=override.get( + "scheduled_target_component_code", + component.scheduled_target_component_code, + ), + scheduled_application_period_id=override.get( + "scheduled_application_period_id", + component.scheduled_application_period_id, + ), ) ) custom = list( PayrollCustomComponent.objects.filter(is_active=True) - .select_related("scope_period", "hidden_from_period") + .select_related( + "scope_period", + "hidden_from_period", + "scheduled_application_period", + ) .prefetch_related("excluded_periods") .order_by("display_order", "name", "id") ) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 4c7be491..bf6eb23a 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -40,6 +40,7 @@ from .attendance import ( ) from .balances import opening_balances from .config import base_rate_code, build_rules +from .scheduled_changes import applied_change_payloads, effective_period_lines from .work_norm import ( calculate_period_personnel_daily_points, calculate_period_personnel_points, @@ -189,6 +190,8 @@ def _component_definition( scope_period_id=None, is_custom=False, highlight_color="", + scheduled_target_component_code="", + scheduled_application_period_id=None, ): return { "code": code, @@ -203,6 +206,8 @@ def _component_definition( "scope_period_id": scope_period_id, "is_custom": is_custom, "highlight_color": highlight_color, + "scheduled_target_component_code": scheduled_target_component_code, + "scheduled_application_period_id": scheduled_application_period_id, } @@ -230,7 +235,7 @@ def _source_component_amounts(records, component_definitions, component_order): amounts = defaultdict(lambda: Decimal("0")) for record in records: component = record.component - amounts[component.code] += record.amount + amounts[component.code] += record.signed_amount component_order.setdefault( component.code, _component_definition( @@ -408,6 +413,8 @@ def build_payroll_period_table(period: PayrollPeriod): scope_period_id=component.scope_period_id, is_custom=component.is_custom, highlight_color=component.highlight_color, + scheduled_target_component_code=(component.scheduled_target_component_code), + scheduled_application_period_id=(component.scheduled_application_period_id), ) for component in components } @@ -416,6 +423,11 @@ def build_payroll_period_table(period: PayrollPeriod): for component in components if component.code not in SYSTEM_COMPONENT_CODES } + scheduled_component_codes = { + code + for code, definition in component_definitions.items() + if definition["kind"] == "scheduled_change" + } calculator = DeterministicPayrollCalculator() active_rules = build_rules() preview_rules = replace(active_rules, allow_negative_payable=True) @@ -427,7 +439,21 @@ def build_payroll_period_table(period: PayrollPeriod): statement = statements.get(employee.pk) rate = rates.get(employee.pk) work = work_records.get(employee.pk) - source_lines = input_lines.get(employee.pk, []) + raw_source_lines = input_lines.get(employee.pk, []) + source_lines = effective_period_lines( + period, + employee, + raw_source_lines, + scheduled_codes=scheduled_component_codes, + ) + display_source_lines = [ + line + for line in raw_source_lines + if line in source_lines + or component_definitions.get(line.component_code, {}).get("kind") + == "scheduled_change" + ] + applied_scheduled_changes = applied_change_payloads(source_lines) schedule, target_points_source = resolve_employee_schedule(employee) automatic_target_points, scheduled_workdays, _ = calculate_period_target_points( period, @@ -444,6 +470,20 @@ def build_payroll_period_table(period: PayrollPeriod): component_definitions, component_order, ) + component_amounts.update( + _source_component_amounts( + [ + line + for line in display_source_lines + if component_definitions.get(line.component_code, {}).get( + "kind" + ) + == "scheduled_change" + ], + component_definitions, + component_order, + ) + ) status = "calculated" totals = { "gross_before_adjustments": str(statement.gross_before_adjustments), @@ -484,7 +524,7 @@ def build_payroll_period_table(period: PayrollPeriod): ) else: component_amounts = _source_component_amounts( - source_lines, + display_source_lines, component_definitions, component_order, ) @@ -629,6 +669,7 @@ def build_payroll_period_table(period: PayrollPeriod): "closing_balance": str(closing_balance), "payment_confirmation": payment_confirmation, "calculation_issue": calculation_issue, + "applied_scheduled_changes": applied_scheduled_changes, **totals, } ) diff --git a/backend/finance/payroll/scheduled_changes.py b/backend/finance/payroll/scheduled_changes.py new file mode 100644 index 00000000..e0b9e1af --- /dev/null +++ b/backend/finance/payroll/scheduled_changes.py @@ -0,0 +1,144 @@ +"""Helpers for signed, one-period payroll component changes.""" + +from __future__ import annotations + +from decimal import Decimal + +from django.db.models import Q + +from employees.constants import ACTION_DISMISSED +from employees.services.personnel_state import resolve_employee_personnel_state +from finance.enums import ApprovalStatus, PayrollComponentKind +from finance.models import PayrollCustomComponent, PayrollInputLine, PayrollPeriod +from requests_app.enums import RequestStatus + +SCHEDULED_CHANGE_SOURCE_PREFIX = "payroll-scheduled-change:" + + +def is_generated_scheduled_change(line: PayrollInputLine) -> bool: + return line.source_ref.startswith(SCHEDULED_CHANGE_SOURCE_PREFIX) + + +def generated_source_line_id(line: PayrollInputLine) -> int | None: + if not is_generated_scheduled_change(line): + return None + raw_id = line.source_ref.removeprefix(SCHEDULED_CHANGE_SOURCE_PREFIX) + try: + return int(raw_id) + except (TypeError, ValueError): + return None + + +def scheduled_component_for_period(code: str, period: PayrollPeriod): + component = ( + PayrollCustomComponent.objects.select_related( + "scope_period", + "hidden_from_period", + "scheduled_application_period", + ) + .prefetch_related("excluded_periods") + .filter( + code=code, + is_active=True, + kind=PayrollComponentKind.SCHEDULED_CHANGE, + ) + .first() + ) + if component is None or not component.applies_to_period(period): + return None + return component + + +def employee_is_eligible_for_scheduled_change(employee, period: PayrollPeriod) -> bool: + if not employee.is_active: + return False + actions = getattr(employee, "payroll_personnel_actions", None) + if actions is None: + actions = list( + employee.actions.filter( + Q(source_request__isnull=True) + | Q(source_request__status=RequestStatus.APPROVED) + ).select_related("source_request") + ) + state = resolve_employee_personnel_state(employee, period.date_to, actions=actions) + return state.status != ACTION_DISMISSED + + +def effective_period_lines( + period: PayrollPeriod, + employee, + lines: list[PayrollInputLine], + *, + scheduled_codes: set[str] | None = None, +) -> list[PayrollInputLine]: + """Remove marker inputs and inapplicable generated effects from calculation.""" + + eligible = employee_is_eligible_for_scheduled_change(employee, period) + if scheduled_codes is None: + scheduled_codes = set( + PayrollCustomComponent.objects.filter( + kind=PayrollComponentKind.SCHEDULED_CHANGE, + ).values_list("code", flat=True) + ) + return [ + line + for line in lines + if line.component_code not in scheduled_codes + and (eligible or not is_generated_scheduled_change(line)) + ] + + +def target_cell_scheduled_delta( + *, + period: PayrollPeriod, + employee_id: int, + component_code: str, +) -> Decimal: + lines = PayrollInputLine.objects.filter( + period=period, + employee_id=employee_id, + component_code=component_code, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX, + ) + return sum((line.signed_amount for line in lines), Decimal("0")) + + +def applied_change_payloads(lines: list[PayrollInputLine]) -> list[dict[str, object]]: + generated = [line for line in lines if is_generated_scheduled_change(line)] + source_ids = { + source_id + for line in generated + if (source_id := generated_source_line_id(line)) is not None + } + sources = { + line.pk: line + for line in PayrollInputLine.objects.filter(pk__in=source_ids).select_related( + "period" + ) + } + components = { + component.code: component + for component in PayrollCustomComponent.objects.filter( + code__in={line.component_code for line in sources.values()} + ) + } + payloads = [] + for generated_line in generated: + source = sources.get(generated_source_line_id(generated_line)) + if source is None: + continue + component = components.get(source.component_code) + if component is None: + continue + payloads.append( + { + "target_component_code": generated_line.component_code, + "amount": str(generated_line.signed_amount), + "source_period_id": source.period_id, + "source_period_code": source.period.code, + "source_component_code": source.component_code, + "source_component_name": component.name, + } + ) + return payloads diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 90afbb45..25ddb576 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -60,6 +60,13 @@ from .config import ( ruleset_period_details, ) from .exceptions import PayrollOperationError, PayrollPermissionDenied +from .scheduled_changes import ( + SCHEDULED_CHANGE_SOURCE_PREFIX, + employee_is_eligible_for_scheduled_change, + is_generated_scheduled_change, + scheduled_component_for_period, + target_cell_scheduled_delta, +) from .work_norm import calculate_period_target_points Employee = get_user_model() @@ -138,6 +145,22 @@ def _mark_linked_vacation_instruction_posted(line: PayrollInputLine) -> None: terms.save(update_fields=["finance_status", "updated_at"]) +def _sync_generated_scheduled_change_approval(line: PayrollInputLine) -> None: + component = scheduled_component_for_period(line.component_code, line.period) + if component is None: + return + PayrollInputLine.objects.filter( + source=InputSource.API, + source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}", + status=ApprovalStatus.DRAFT, + ).update( + status=ApprovalStatus.APPROVED, + approved_by=line.approved_by, + approved_at=line.approved_at, + self_approval_overridden=line.self_approval_overridden, + ) + + def _resolve_rates( period: PayrollPeriod, employee_ids: list[int], @@ -1952,6 +1975,7 @@ def clear_component_input_lines( employee_id: int, component_code: str, actor, + validate_scheduled_result: bool = True, ) -> dict[str, int]: """Clear one spreadsheet-like component cell without losing its audit trail.""" @@ -1960,8 +1984,40 @@ def clear_component_input_lines( if period is None: _operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.") _ensure_period_accepts_approvals(period) - if not Employee.objects.filter(pk=employee_id).exists(): + employee = Employee.objects.filter(pk=employee_id).first() + if employee is None: _operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.") + scheduled_component = scheduled_component_for_period(component_code, period) + if scheduled_component is not None: + target_period = PayrollPeriod.objects.select_for_update().get( + pk=scheduled_component.scheduled_application_period_id + ) + try: + _ensure_period_accepts_approvals(target_period) + except PayrollOperationError as exc: + _operation_error( + "TARGET_PERIOD_INPUTS_LOCKED", + "Период применения уже заблокирован для изменений.", + application_period_id=target_period.pk, + original_code=exc.code, + ) + elif validate_scheduled_result and employee_is_eligible_for_scheduled_change( + employee, period + ): + scheduled_delta = target_cell_scheduled_delta( + period=period, + employee_id=employee_id, + component_code=component_code, + ) + if scheduled_delta < 0: + _operation_error( + "NEGATIVE_SCHEDULED_TARGET", + ( + "Нельзя очистить обычное значение: применённый маркер " + "сделает итог колонки отрицательным." + ), + scheduled_delta=str(scheduled_delta), + ) lines = list( PayrollInputLine.objects.select_for_update() .filter( @@ -1972,6 +2028,21 @@ def clear_component_input_lines( ) .order_by("pk") ) + if scheduled_component is None: + lines = [line for line in lines if not is_generated_scheduled_change(line)] + generated_lines = [] + if scheduled_component is not None and lines: + generated_refs = [ + f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}" for line in lines + ] + generated_lines = list( + PayrollInputLine.objects.select_for_update() + .filter( + source_ref__in=generated_refs, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .order_by("pk") + ) now = timezone.now() summary = {"draft": 0, "approved": 0, "total": len(lines)} for line in lines: @@ -1996,6 +2067,14 @@ def clear_component_input_lines( period=period, metadata={"previous_status": previous_status}, ) + for generated in generated_lines: + generated.status = ApprovalStatus.VOIDED + generated.voided_by = actor + generated.voided_at = now + generated.void_reason = "Исходное запланированное изменение очищено" + generated.save( + update_fields=["status", "voided_by", "voided_at", "void_reason"] + ) return summary @@ -2019,17 +2098,47 @@ def set_component_input_line_value( "Колонка недоступна в выбранном расчётном периоде.", details={"component_code": component_code}, ) from exc + scheduled_component = scheduled_component_for_period(component_code, period) + signed_amount = Decimal(amount) + if signed_amount == 0: + _operation_error( + "INPUT_VALIDATION_FAILED", + "Значение ячейки не может быть нулевым.", + ) + stored_amount = signed_amount + if scheduled_component is None: + employee = Employee.objects.filter(pk=employee_id).first() + if employee is None: + _operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.") + if employee_is_eligible_for_scheduled_change(employee, period): + stored_amount -= target_cell_scheduled_delta( + period=period, + employee_id=employee_id, + component_code=component_code, + ) + if stored_amount <= 0: + _operation_error( + "INVALID_COMPONENT_BASE", + ( + "Итоговое значение должно быть больше уже применённого " + "запланированного изменения." + ), + desired_amount=str(signed_amount), + scheduled_delta=str(signed_amount - stored_amount), + ) clear_component_input_lines( period_id, employee_id=employee_id, component_code=component_code, actor=actor, + validate_scheduled_result=False, ) line = PayrollInputLine( period=period, employee_id=employee_id, component_code=component_code, - amount=amount, + amount=abs(stored_amount), + value_sign=-1 if stored_amount < 0 else 1, reason="Перезаписано в итоговой таблице", status=ApprovalStatus.DRAFT, source=InputSource.MANUAL, @@ -2048,6 +2157,87 @@ def set_component_input_line_value( ) }, ) from exc + if scheduled_component is not None: + target_period = PayrollPeriod.objects.select_for_update().get( + pk=scheduled_component.scheduled_application_period_id + ) + generated = PayrollInputLine( + period=target_period, + employee_id=employee_id, + component_code=scheduled_component.scheduled_target_component_code, + amount=line.amount, + value_sign=line.value_sign, + reason=( + f"Применено из колонки «{scheduled_component.name}» " + f"периода {period.code}" + ), + status=ApprovalStatus.DRAFT, + source=InputSource.API, + source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}", + include_in_carryover=False, + created_by=actor, + ) + try: + generated.full_clean() + generated.save(force_insert=True) + except ValidationError as exc: + raise PayrollOperationError( + "INPUT_VALIDATION_FAILED", + "Запланированное изменение не прошло проверку.", + details={ + "errors": ( + exc.message_dict + if hasattr(exc, "message_dict") + else exc.messages + ) + }, + ) from exc + target_lines = PayrollInputLine.objects.filter( + period=target_period, + employee_id=employee_id, + component_code=generated.component_code, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + base_amount = sum( + ( + target_line.signed_amount + for target_line in target_lines + if not is_generated_scheduled_change(target_line) + ), + Decimal("0"), + ) + scheduled_delta = sum( + ( + target_line.signed_amount + for target_line in target_lines + if is_generated_scheduled_change(target_line) + ), + Decimal("0"), + ) + result_amount = base_amount + scheduled_delta + if result_amount < 0: + _operation_error( + "NEGATIVE_SCHEDULED_TARGET", + "Запланированное изменение делает значение колонки отрицательным.", + application_period_id=target_period.pk, + target_component_code=generated.component_code, + current_amount=str(base_amount), + scheduled_delta=str(scheduled_delta), + result_amount=str(result_amount), + ) + target_point_base = point_base_accruals_for_employees( + period=target_period, + employee_ids=[employee_id], + )[employee_id] + if target_point_base < 0: + _operation_error( + "NEGATIVE_POINT_BASE", + "Изменение создаёт отрицательную балльную базу в периоде применения.", + employee_id=employee_id, + component_code=generated.component_code, + application_period_id=target_period.pk, + point_base=str(target_point_base), + ) point_base_accrual = point_base_accruals_for_employees( period=period, employee_ids=[employee_id], @@ -2068,7 +2258,7 @@ def set_component_input_line_value( action="payroll.input_line_draft_created_from_table", instance=line, period=period, - metadata={"component_code": component_code, "amount": str(amount)}, + metadata={"component_code": component_code, "amount": str(signed_amount)}, ) return line @@ -2118,6 +2308,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: period=period, status=ApprovalStatus.DRAFT, ) + .exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX) .order_by("pk") .values_list("pk", "component_code") ) @@ -2152,6 +2343,24 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: "После публикации показатели меняются только новой ревизией.", ) + for line in input_lines: + component = scheduled_component_for_period(line.component_code, period) + if component is not None: + target_period = next( + item + for item in periods + if item.pk == component.scheduled_application_period_id + ) + try: + _ensure_period_accepts_approvals(target_period) + except PayrollOperationError as exc: + _operation_error( + "TARGET_PERIOD_INPUTS_LOCKED", + "Период применения уже заблокирован для изменений.", + application_period_id=target_period.pk, + original_code=exc.code, + ) + for rate in rates: _approve_record( rate, @@ -2174,6 +2383,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: expected_lock_version=line.lock_version, ) _mark_linked_vacation_instruction_posted(approved_line) + _sync_generated_scheduled_change_approval(approved_line) summary = { "rates": len(rates), @@ -2275,6 +2485,25 @@ def approve_input_line( 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) + if is_generated_scheduled_change(record): + _operation_error( + "SCHEDULED_CHANGE_DERIVED", + "Автоматическая строка утверждается вместе с исходным маркером.", + ) + component = scheduled_component_for_period(record.component_code, period) + if component is not None: + target_period = PayrollPeriod.objects.select_for_update().get( + pk=component.scheduled_application_period_id + ) + try: + _ensure_period_accepts_approvals(target_period) + except PayrollOperationError as exc: + _operation_error( + "TARGET_PERIOD_INPUTS_LOCKED", + "Период применения уже заблокирован для изменений.", + application_period_id=target_period.pk, + original_code=exc.code, + ) approved_line = _approve_record( record, actor=actor, @@ -2282,6 +2511,7 @@ def approve_input_line( expected_lock_version=expected_lock_version, ) _mark_linked_vacation_instruction_posted(approved_line) + _sync_generated_scheduled_change_approval(approved_line) return approved_line diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 242fd0f6..d04b3ec7 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -213,6 +213,8 @@ def test_manager_can_customize_system_component_behavior( "visible_to_employee": False, "is_custom": False, "highlight_color": "violet", + "scheduled_target_component_code": "", + "scheduled_application_period_id": None, } override = FinanceSettings.get_solo().payroll_component_overrides["BONUS"] assert override["include_in_carryover"] is True diff --git a/backend/tests/finance/test_payroll_scheduled_changes.py b/backend/tests/finance/test_payroll_scheduled_changes.py new file mode 100644 index 00000000..ccc829d3 --- /dev/null +++ b/backend/tests/finance/test_payroll_scheduled_changes.py @@ -0,0 +1,1300 @@ +"""Executable contract for payroll columns that schedule a one-period change. + +The production implementation intentionally follows these tests. A scheduled +change is configured once on a custom column, contains one signed value per +employee cell, does not affect its source period and changes one target column +in one explicitly selected future period. +""" + +import uuid +from datetime import datetime + +import pytest +from django.contrib.auth.models import Permission +from django.urls import reverse +from django.utils import timezone + +from employees.constants import ACTION_DISMISSED, ACTION_REHIRED +from employees.models import EmployeeAction +from finance.models import ( + PayrollInputCopyConfiguration, + PayrollInputLine, + PayrollPeriod, +) + +pytestmark = pytest.mark.django_db + +SCHEDULED_CHANGE_KIND = "scheduled_change" + + +def grant(user, *codenames): + permissions = Permission.objects.filter( + content_type__app_label="finance", + codename__in=codenames, + ) + assert permissions.count() == len(codenames) + user.user_permissions.add(*permissions) + for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, cache_name): + delattr(user, cache_name) + + +def api_url(name, **kwargs): + return reverse(f"api:v1:finance-payroll:{name}", kwargs=kwargs or None) + + +def make_period(*, creator, month): + names = { + 6: "Июнь 2026", + 7: "Июль 2026", + 8: "Август 2026", + 9: "Сентябрь 2026", + } + last_days = {6: 30, 7: 31, 8: 31, 9: 30} + return PayrollPeriod.objects.create( + code=f"2026-{month:02d}", + name=names[month], + date_from=f"2026-{month:02d}-01", + date_to=f"2026-{month:02d}-{last_days[month]}", + pay_date=f"2026-{month + 1:02d}-05", + currency="RUB", + created_by=creator, + ) + + +def component_payload( + *, + name, + source_period, + application_period, + target_component_code="BONUS", +): + return { + "name": name, + "kind": SCHEDULED_CHANGE_KIND, + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": False, + "highlight_color": "blue", + "period_scope": "current_and_future", + "period_id": source_period.pk, + "scheduled_target_component_code": target_component_code, + "scheduled_application_period_id": application_period.pk, + } + + +def create_scheduled_component( + client, + *, + name, + source_period, + application_period, + target_component_code="BONUS", +): + response = client.post( + api_url("admin-component-list"), + component_payload( + name=name, + source_period=source_period, + application_period=application_period, + target_component_code=target_component_code, + ), + format="json", + ) + assert response.status_code == 201, response.content + return response.json() + + +def set_cell(client, *, period, employee, component_code, amount): + return client.post( + api_url("admin-component-cell-set", pk=period.pk), + { + "employee_id": employee.pk, + "component_code": component_code, + "amount": amount, + }, + format="json", + ) + + +def clear_cell(client, *, period, employee, component_code): + return client.post( + api_url("admin-component-cell-clear", pk=period.pk), + { + "employee_id": employee.pk, + "component_code": component_code, + }, + format="json", + ) + + +def period_table(client, period): + response = client.get(api_url("admin-period-table", pk=period.pk)) + assert response.status_code == 200, response.content + return response.json() + + +def employee_row(table, employee): + return next(row for row in table["rows"] if row["employee"]["id"] == employee.pk) + + +@pytest.fixture +def scheduled_workspace(user_factory, auth_client_factory): + manager = user_factory(email="scheduled.change.manager@example.test") + employee = user_factory(email="scheduled.change.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + periods = { + month: make_period(creator=manager, month=month) for month in (6, 7, 8, 9) + } + return { + "manager": manager, + "employee": employee, + "client": auth_client_factory(manager), + "periods": periods, + } + + +def test_scheduled_component_has_one_target_and_one_future_application_period( + scheduled_workspace, +): + workspace = scheduled_workspace + component = create_scheduled_component( + workspace["client"], + name="Плановое изменение премии", + source_period=workspace["periods"][6], + application_period=workspace["periods"][8], + ) + + assert component["kind"] == SCHEDULED_CHANGE_KIND + assert component["scheduled_target_component_code"] == "BONUS" + assert component["scheduled_application_period_id"] == workspace["periods"][8].pk + assert component["period_scope"] == "current_and_future" + assert component["scope_period_id"] == workspace["periods"][6].pk + assert component["include_in_point_base"] is False + assert component["include_in_carryover"] is False + assert component["visible_to_employee"] is False + + +@pytest.mark.parametrize( + ("target_code", "application_month", "expected_field"), + [ + ("MISSING_COMPONENT", 8, "scheduled_target_component_code"), + ("BONUS", 6, "scheduled_application_period_id"), + ], +) +def test_scheduled_component_rejects_missing_target_or_non_future_period( + scheduled_workspace, + target_code, + application_month, + expected_field, +): + workspace = scheduled_workspace + response = workspace["client"].post( + api_url("admin-component-list"), + component_payload( + name="Некорректный маркер", + source_period=workspace["periods"][6], + application_period=workspace["periods"][application_month], + target_component_code=target_code, + ), + format="json", + ) + + assert response.status_code == 400, response.content + assert expected_field in response.json() + + +def test_scheduled_component_rejects_closed_application_period(scheduled_workspace): + workspace = scheduled_workspace + target = workspace["periods"][8] + target.status = "closed" + target.save(update_fields=["status"]) + + response = workspace["client"].post( + api_url("admin-component-list"), + component_payload( + name="Маркер в закрытый период", + source_period=workspace["periods"][6], + application_period=target, + ), + format="json", + ) + + assert response.status_code == 400, response.content + assert "scheduled_application_period_id" in response.json() + + +def test_system_component_cannot_be_converted_to_a_scheduled_marker( + scheduled_workspace, +): + workspace = scheduled_workspace + response = workspace["client"].patch( + api_url("admin-component-detail", code="BONUS"), + component_payload( + name="Премия", + source_period=workspace["periods"][6], + application_period=workspace["periods"][8], + target_component_code="VACATION_PAY", + ), + format="json", + ) + + assert response.status_code == 400, response.content + assert "kind" in response.json() + + +@pytest.mark.parametrize( + ("marker_amount", "expected_target_amount"), + [("500.00", "1500.00"), ("-250.00", "750.00")], +) +def test_signed_marker_does_not_change_source_but_changes_selected_target_period( + scheduled_workspace, + marker_amount, + expected_target_amount, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name=f"Изменение премии {marker_amount}", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=source, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + source_before = employee_row(period_table(client, source), employee) + + saved = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount=marker_amount, + ) + assert saved.status_code == 201, saved.content + + source_row = employee_row(period_table(client, source), employee) + assert source_row["component_amounts"][component["code"]] == marker_amount + for field in ("gross_before_adjustments", "gross_total", "payable"): + assert source_row[field] == source_before[field] + + target_row = employee_row(period_table(client, target), employee) + assert target_row["component_amounts"]["BONUS"] == expected_target_amount + assert target_row["gross_before_adjustments"] == expected_target_amount + assert target_row["applied_scheduled_changes"] == [ + { + "target_component_code": "BONUS", + "amount": marker_amount, + "source_period_id": source.pk, + "source_period_code": source.code, + "source_component_code": component["code"], + "source_component_name": component["name"], + } + ] + + +def test_marker_applies_without_any_normal_period_copy(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Самостоятельный прирост", + source_period=source, + application_period=target, + ) + + saved = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert saved.status_code == 201, saved.content + + target_row = employee_row(period_table(client, target), employee) + assert target_row["component_amounts"]["BONUS"] == "500.00" + + +def test_editing_target_cell_treats_entered_amount_as_final_visible_value( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Изменение перед ручной правкой", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + assert ( + set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ).status_code + == 201 + ) + + edited = set_cell( + client, + period=target, + employee=employee, + component_code="BONUS", + amount="2000.00", + ) + + assert edited.status_code == 201, edited.content + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "2000.00" + manual_amount = sum( + line.signed_amount + for line in PayrollInputLine.objects.filter( + period=target, + employee=employee, + component_code="BONUS", + status__in=("draft", "approved"), + ) + if not line.source_ref.startswith("payroll-scheduled-change:") + ) + assert str(manual_amount) == "1500.00" + + +def test_clearing_target_is_blocked_when_negative_marker_would_make_it_invalid( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Отрицательное изменение перед очисткой", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + assert ( + set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="-250.00", + ).status_code + == 201 + ) + + cleared = clear_cell( + client, + period=target, + employee=employee, + component_code="BONUS", + ) + + assert cleared.status_code == 409, cleared.content + assert cleared.json()["code"] == "NEGATIVE_SCHEDULED_TARGET" + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "750.00" + + +def test_markers_from_same_column_across_periods_and_other_columns_are_summed( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + intermediate = workspace["periods"][7] + target = workspace["periods"][8] + first = create_scheduled_component( + client, + name="Первое изменение премии", + source_period=source, + application_period=target, + ) + second = create_scheduled_component( + client, + name="Второе изменение премии", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + + for period, component, amount in ( + (source, first, "500.00"), + (intermediate, first, "-125.00"), + (intermediate, second, "75.00"), + ): + response = set_cell( + client, + period=period, + employee=employee, + component_code=component["code"], + amount=amount, + ) + assert response.status_code == 201, response.content + + target_row = employee_row(period_table(client, target), employee) + assert target_row["component_amounts"]["BONUS"] == "1450.00" + assert ( + sum( + change["amount"] in {"500.00", "-125.00", "75.00"} + for change in target_row["applied_scheduled_changes"] + ) + == 3 + ) + + +def test_marker_column_is_visible_before_but_not_from_application_period( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + intermediate = workspace["periods"][7] + target = workspace["periods"][8] + following = workspace["periods"][9] + component = create_scheduled_component( + client, + name="Маркер с конечным периодом", + source_period=source, + application_period=target, + ) + + assert component["code"] in { + column["code"] for column in period_table(client, source)["component_columns"] + } + assert component["code"] in { + column["code"] + for column in period_table(client, intermediate)["component_columns"] + } + assert component["code"] not in { + column["code"] for column in period_table(client, target)["component_columns"] + } + assert component["code"] not in { + column["code"] + for column in period_table(client, following)["component_columns"] + } + + +def test_reentering_same_cell_replaces_its_net_value_instead_of_stacking( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Один итог на ячейку", + source_period=source, + application_period=target, + ) + + for amount in ("500.00", "700.00"): + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount=amount, + ) + assert response.status_code == 201, response.content + + source_row = employee_row(period_table(client, source), employee) + target_row = employee_row(period_table(client, target), employee) + assert source_row["component_amounts"][component["code"]] == "700.00" + assert target_row["component_amounts"]["BONUS"] == "700.00" + assert len(target_row["applied_scheduled_changes"]) == 1 + + +def test_marker_value_cannot_be_entered_in_or_after_application_period( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Маркер только до назначения", + source_period=source, + application_period=target, + ) + + for period in (target, workspace["periods"][9]): + rejected = set_cell( + client, + period=period, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert rejected.status_code == 409, rejected.content + assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE" + + +@pytest.mark.parametrize("existing_mode", ["skip", "replace"]) +def test_manual_copy_order_does_not_drop_or_double_the_marker( + scheduled_workspace, + existing_mode, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name=f"Изменение до переноса {existing_mode}", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=source, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + assert ( + employee_row(period_table(client, target), employee)["component_amounts"][ + "BONUS" + ] + == "500.00" + ) + + copied = client.post( + api_url("admin-period-copy-input-lines", pk=target.pk), + { + "source_period_id": source.pk, + "component_codes": ["BONUS"], + "employee_scope": "selected", + "employee_ids": [employee.pk], + "existing_mode": existing_mode, + "expected_period_lock_version": target.lock_version, + "idempotency_key": str(uuid.uuid4()), + }, + format="json", + ) + assert copied.status_code == 201, copied.content + + target_row = employee_row(period_table(client, target), employee) + assert target_row["component_amounts"]["BONUS"] == "1500.00" + assert len(target_row["applied_scheduled_changes"]) == 1 + + +def test_adjusted_value_copies_normally_later_without_copying_marker_badge( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + applied = workspace["periods"][8] + following = workspace["periods"][9] + component = create_scheduled_component( + client, + name="Разовый прирост до сентября", + source_period=source, + application_period=applied, + ) + PayrollInputLine.objects.create( + period=applied, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + assert ( + employee_row(period_table(client, applied), employee)["component_amounts"][ + "BONUS" + ] + == "1500.00" + ) + + copied = client.post( + api_url("admin-period-copy-input-lines", pk=following.pk), + { + "source_period_id": applied.pk, + "component_codes": ["BONUS"], + "employee_scope": "selected", + "employee_ids": [employee.pk], + "existing_mode": "replace", + "expected_period_lock_version": following.lock_version, + "idempotency_key": str(uuid.uuid4()), + }, + format="json", + ) + assert copied.status_code == 201, copied.content + + following_row = employee_row(period_table(client, following), employee) + assert following_row["component_amounts"]["BONUS"] == "1500.00" + assert following_row["applied_scheduled_changes"] == [] + assert component["code"] not in { + column["code"] + for column in period_table(client, following)["component_columns"] + } + + +def test_editing_and_clearing_marker_recalculates_open_target_once( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Редактируемое изменение", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + + for amount, expected in ( + ("500.00", "1500.00"), + ("-200.00", "800.00"), + ("-200.00", "800.00"), + ): + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount=amount, + ) + assert response.status_code == 201, response.content + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == expected + assert len(row["applied_scheduled_changes"]) == 1 + + cleared = clear_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + ) + assert cleared.status_code == 200, cleared.content + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "1000.00" + assert row["applied_scheduled_changes"] == [] + + +def test_automatic_copy_carries_effective_value_but_not_marker(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + manager = workspace["manager"] + employee = workspace["employee"] + source = workspace["periods"][6] + applied = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Маркер перед автопереносом", + source_period=source, + application_period=applied, + ) + PayrollInputLine.objects.create( + period=applied, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=manager, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + PayrollInputCopyConfiguration.objects.create( + owner=manager, + component_codes=["BONUS", component["code"]], + employee_scope="selected", + employee_ids=[employee.pk], + existing_mode="replace", + auto_apply_on_period_creation=True, + ) + workspace["periods"][9].delete() + + created = client.post( + api_url("admin-period-list"), + { + "code": "2026-09", + "name": "Сентябрь 2026", + "date_from": "2026-09-01", + "date_to": "2026-09-30", + "pay_date": "2026-10-05", + "currency": "RUB", + }, + format="json", + ) + + assert created.status_code == 201, created.content + assert created.json()["automatic_input_copy"]["summary"]["created"] == 1 + following = PayrollPeriod.objects.get(pk=created.json()["id"]) + row = employee_row(period_table(client, following), employee) + assert row["component_amounts"]["BONUS"] == "1500.00" + assert row["applied_scheduled_changes"] == [] + assert component["code"] not in { + column["code"] + for column in period_table(client, following)["component_columns"] + } + + +def test_marker_is_not_applied_to_employee_dismissed_before_target( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Изменение перед увольнением", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.make_aware(datetime(2026, 7, 15)), + ) + + row = employee_row(period_table(client, target), employee) + assert "BONUS" not in row["component_amounts"] + assert row["applied_scheduled_changes"] == [] + + +def test_marker_is_applied_when_employee_is_rehired_before_target( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Изменение после возврата", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.make_aware(datetime(2026, 7, 10)), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_REHIRED, + date=timezone.make_aware(datetime(2026, 7, 25)), + ) + + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "500.00" + assert len(row["applied_scheduled_changes"]) == 1 + + +@pytest.mark.parametrize("delete_mode", ["current", "current_and_future"]) +def test_marker_column_with_values_must_be_cleared_before_deletion( + scheduled_workspace, + delete_mode, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name=f"Удаляемый маркер {delete_mode}", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + + blocked = client.delete( + api_url("admin-component-detail", code=component["code"]), + {"period_id": source.pk, "mode": delete_mode}, + format="json", + ) + assert blocked.status_code == 409, blocked.content + assert blocked.json()["code"] == "COMPONENT_HAS_INPUTS" + + cleared = clear_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + ) + assert cleared.status_code == 200, cleared.content + deleted = client.delete( + api_url("admin-component-detail", code=component["code"]), + {"period_id": source.pk, "mode": delete_mode}, + format="json", + ) + assert deleted.status_code == 204, deleted.content + row = employee_row(period_table(client, target), employee) + assert "BONUS" not in row["component_amounts"] + assert row["applied_scheduled_changes"] == [] + + +def test_target_column_cannot_be_hidden_while_a_marker_references_it( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + target = workspace["periods"][8] + target_response = client.post( + api_url("admin-component-list"), + { + "name": "Будущая надбавка", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "all", + }, + format="json", + ) + assert target_response.status_code == 201, target_response.content + target_component = target_response.json() + create_scheduled_component( + client, + name="Изменение будущей надбавки", + source_period=source, + application_period=target, + target_component_code=target_component["code"], + ) + + blocked = client.delete( + api_url("admin-component-detail", code=target_component["code"]), + {"period_id": target.pk, "mode": "current_and_future"}, + format="json", + ) + + assert blocked.status_code == 409, blocked.content + assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES" + + +def test_target_column_cannot_be_scoped_away_from_application_period( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + target = workspace["periods"][8] + created = client.post( + api_url("admin-component-list"), + { + "name": "Будущая премиальная база", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "all", + }, + format="json", + ) + assert created.status_code == 201, created.content + target_component = created.json() + create_scheduled_component( + client, + name="Маркер защищённой цели", + source_period=source, + application_period=target, + target_component_code=target_component["code"], + ) + + blocked = client.patch( + api_url("admin-component-detail", code=target_component["code"]), + { + "name": target_component["name"], + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "current_only", + "period_id": source.pk, + "scheduled_target_component_code": "", + "scheduled_application_period_id": None, + }, + format="json", + ) + + assert blocked.status_code == 409, blocked.content + assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES" + + +def test_marker_rejects_target_column_unavailable_in_application_period( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + target = workspace["periods"][8] + target_response = client.post( + api_url("admin-component-list"), + { + "name": "Только июньская надбавка", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "current_only", + "period_id": source.pk, + }, + format="json", + ) + assert target_response.status_code == 201, target_response.content + + rejected = client.post( + api_url("admin-component-list"), + component_payload( + name="Маркер в отсутствующую колонку", + source_period=source, + application_period=target, + target_component_code=target_response.json()["code"], + ), + format="json", + ) + + assert rejected.status_code == 400, rejected.content + assert "scheduled_target_component_code" in rejected.json() + + +def test_cosmetic_marker_edit_preserves_routing_and_effect(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Старое название маркера", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + payload = component_payload( + name="Новое название маркера", + source_period=source, + application_period=target, + ) + payload["highlight_color"] = "violet" + + changed = client.patch( + api_url("admin-component-detail", code=component["code"]), + payload, + format="json", + ) + + assert changed.status_code == 200, changed.content + assert changed.json()["name"] == "Новое название маркера" + assert changed.json()["highlight_color"] == "violet" + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "500.00" + assert row["applied_scheduled_changes"][0]["source_component_name"] == ( + "Новое название маркера" + ) + + +def test_marker_routing_cannot_change_after_values_are_entered(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Зафиксированный маршрут", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + payload = component_payload( + name=component["name"], + source_period=source, + application_period=workspace["periods"][9], + ) + + changed = client.patch( + api_url("admin-component-detail", code=component["code"]), + payload, + format="json", + ) + + assert changed.status_code == 409, changed.content + assert changed.json()["code"] == "COMPONENT_SCHEDULE_HAS_INPUTS" + + +def test_closed_target_prevents_retroactive_marker_change_or_clear( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Маркер закрываемого периода", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + target.status = "closed" + target.save(update_fields=["status"]) + + changed = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="600.00", + ) + cleared = clear_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + ) + + assert changed.status_code == 409, changed.content + assert changed.json()["code"] == "TARGET_PERIOD_INPUTS_LOCKED" + assert cleared.status_code == 409, cleared.content + assert cleared.json()["code"] == "TARGET_PERIOD_INPUTS_LOCKED" + + +def test_negative_marker_cannot_make_target_cell_negative(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Слишком большое уменьшение", + source_period=source, + application_period=target, + ) + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="100.00", + created_by=workspace["manager"], + ) + + rejected = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="-150.00", + ) + + assert rejected.status_code == 409, rejected.content + assert rejected.json()["code"] == "NEGATIVE_SCHEDULED_TARGET" + assert rejected.json()["details"] == { + "application_period_id": str(target.pk), + "target_component_code": "BONUS", + "current_amount": "100.00", + "scheduled_delta": "-150.00", + "result_amount": "-50.00", + } + assert not PayrollInputLine.objects.filter( + period=source, + employee=employee, + component_code=component["code"], + status__in=("draft", "approved"), + ).exists() + + +def test_marker_cannot_target_itself_or_another_marker(scheduled_workspace): + workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + target = workspace["periods"][8] + first = create_scheduled_component( + client, + name="Первичный маркер", + source_period=source, + application_period=target, + ) + + response = client.post( + api_url("admin-component-list"), + component_payload( + name="Цепочка маркеров", + source_period=source, + application_period=target, + target_component_code=first["code"], + ), + format="json", + ) + + assert response.status_code == 400, response.content + assert "scheduled_target_component_code" in response.json() + + +def test_marker_component_is_never_offered_or_copied_as_normal_input( + scheduled_workspace, +): + workspace = scheduled_workspace + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][6] + target = workspace["periods"][8] + component = create_scheduled_component( + client, + name="Непереносимый маркер", + source_period=source, + application_period=target, + ) + response = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + assert response.status_code == 201, response.content + + preview = client.get( + api_url("admin-period-copy-input-lines", pk=target.pk), + {"source_period_id": source.pk}, + ) + + assert preview.status_code == 200, preview.content + assert component["code"] not in { + item["code"] for item in preview.json()["components"] + } + assert preview.json()["summary"]["eligible_lines"] == 0 diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 946c10f9..bc01e831 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -1645,7 +1645,9 @@ export function PayrollInputLineFormModal({ > {components - .filter((item) => item.is_active) + .filter( + (item) => item.is_active && item.kind !== "scheduled_change", + ) .map((item) => (