diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index d906f29f..199fd4ad 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -9,7 +9,7 @@ from rest_framework.exceptions import ValidationError from employees.constants import ACTION_DISMISSED from employees.models import Employee from employees.services.personnel_state import resolve_employee_personnel_state -from finance.enums import ApprovalStatus, InputSource +from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind from finance.models import ( PayrollAuditEvent, PayrollInputCopyConfiguration, @@ -48,11 +48,15 @@ def _validate_input_copy_periods(target_period, source_period): def _copyable_input_lines(source_period, *, target_period=None): component_codes = { - component.code for component in get_payroll_components(source_period) + component.code + for component in get_payroll_components(source_period) + if component.kind != PayrollComponentKind.SCHEDULED_CHANGE } if target_period is not None: component_codes &= { - component.code for component in get_payroll_components(target_period) + component.code + for component in get_payroll_components(target_period) + if component.kind != PayrollComponentKind.SCHEDULED_CHANGE } return ( PayrollInputLine.objects.filter( @@ -170,6 +174,7 @@ def _input_copy_preview(target_period, source_period, *, actor): "amount_total": Decimal("0.00"), } for component in get_payroll_components(target_period) + if component.kind != PayrollComponentKind.SCHEDULED_CHANGE } employee_rows = {} for line in lines: 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 bb418969..17f51bc4 100644 --- a/backend/api/v1/finance/admin/input_views/input_lines.py +++ b/backend/api/v1/finance/admin/input_views/input_lines.py @@ -19,7 +19,10 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, ) -from finance.payroll.scheduled_changes import SCHEDULED_CHANGE_SOURCE_PREFIX +from finance.payroll.scheduled_changes import ( + SCHEDULED_CHANGE_SOURCE_PREFIX, + materialize_scheduled_changes, +) from finance.payroll.services import ( approve_input_line, clear_component_input_lines, @@ -200,6 +203,10 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): actor=request.user, data=data, ) + materialize_scheduled_changes( + source_period=source_period, + target_period=target_period, + ) return Response( result, status=( diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 4e6ee7ff..6e701808 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -30,7 +30,10 @@ from finance.payroll.adapter import point_base_accruals_for_employees from finance.payroll.components import ( get_payroll_components, ) -from finance.payroll.scheduled_changes import scheduled_components_targeting +from finance.payroll.scheduled_changes import ( + materialize_scheduled_changes, + scheduled_components_targeting, +) from ...admin_serializers import ( PayrollComponentSerializer, @@ -136,10 +139,6 @@ 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 ( values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE and values.get("scheduled_target_component_code") == code @@ -154,28 +153,25 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): target_references = scheduled_components_targeting(code) reference_periods = PayrollPeriod.objects.in_bulk( { - reference.scheduled_application_period_id + reference.scope_period_id for reference in target_references - if reference.scheduled_application_period_id is not None + if reference.scope_period_id is not None } ) 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 + target_would_be_unavailable = ( + bool(target_references) or target_would_be_unavailable ) 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_id not in reference_periods - or reference_periods[ - reference.scheduled_application_period_id - ].date_from - < scope_period.date_from + or reference.scope_period_id is None + or reference.scope_period_id not in reference_periods + or scope_period.date_from + > reference_periods[reference.scope_period_id].date_from for reference in target_references ) if target_references and target_would_be_unavailable: @@ -186,17 +182,9 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): "Новые настройки должны сохранять её в периоде применения." ), ) - 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 - ) - ) + scheduled_route_changed = current.kind != values["kind"] or getattr( + current, "scheduled_target_component_code", "" + ) != values.get("scheduled_target_component_code", "") 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"]) @@ -242,14 +230,12 @@ 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", - "scheduled_application_period", ] ) except DjangoValidationError as exc: @@ -262,12 +248,8 @@ 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 - ), } + overrides[code].pop("scheduled_application_period_id", None) settings.payroll_component_overrides = overrides settings.save(update_fields=["payroll_component_overrides", "updated_at"]) audit_instance = settings @@ -343,29 +325,6 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id")) mode = request.data.get("mode") scheduled_references = scheduled_components_targeting(code) - reference_periods = PayrollPeriod.objects.in_bulk( - { - reference.scheduled_application_period_id - for reference in scheduled_references - if reference.scheduled_application_period_id is not None - } - ) - if mode == "current": - scheduled_references = [ - reference - for reference in scheduled_references - if reference.scheduled_application_period_id == period.pk - ] - elif mode == "current_and_future": - scheduled_references = [ - reference - for reference in scheduled_references - if reference.scheduled_application_period_id in reference_periods - and reference_periods[ - reference.scheduled_application_period_id - ].date_from - >= period.date_from - ] if scheduled_references: _conflict( "COMPONENT_HAS_SCHEDULED_CHANGES", @@ -452,45 +411,49 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): instance=period, period=period, ) + source_period = ( + PayrollPeriod.objects.select_for_update(of=("self",)) + .filter( + currency=period.currency, + date_to__lt=period.date_from, + ) + .exclude(pk=period.pk) + .order_by("-date_to", "-id") + .first() + ) automatic_copy = None configuration = PayrollInputCopyConfiguration.objects.filter( owner=request.user, auto_apply_on_period_creation=True, ).first() - if configuration is not None: - source_period = ( - PayrollPeriod.objects.select_for_update(of=("self",)) - .filter( - currency=period.currency, - date_to__lt=period.date_from, - ) - .exclude(pk=period.pk) - .order_by("-date_to", "-id") - .first() + if configuration is not None and source_period is not None: + automatic_copy = _apply_input_copy( + target_period=period, + source_period=source_period, + actor=request.user, + data={ + "component_codes": configuration.component_codes, + "employee_scope": configuration.employee_scope, + "employee_ids": configuration.employee_ids, + "existing_mode": configuration.existing_mode, + "idempotency_key": uuid.uuid4(), + }, + ) + _audit_api_mutation( + actor=request.user, + action="payroll.period_input_copy_auto_applied", + instance=period, + period=period, + metadata={ + "source_period_id": source_period.pk, + **automatic_copy["summary"], + }, + ) + if source_period is not None: + materialize_scheduled_changes( + source_period=source_period, + target_period=period, ) - if source_period is not None: - automatic_copy = _apply_input_copy( - target_period=period, - source_period=source_period, - actor=request.user, - data={ - "component_codes": configuration.component_codes, - "employee_scope": configuration.employee_scope, - "employee_ids": configuration.employee_ids, - "existing_mode": configuration.existing_mode, - "idempotency_key": uuid.uuid4(), - }, - ) - _audit_api_mutation( - actor=request.user, - action="payroll.period_input_copy_auto_applied", - instance=period, - period=period, - metadata={ - "source_period_id": source_period.pk, - **automatic_copy["summary"], - }, - ) payload = PayrollPeriodSerializer(period).data payload["automatic_input_copy"] = automatic_copy return Response( diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 98e24d21..a22111e0 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -20,6 +20,7 @@ from finance.enums import ( ) from finance.models import PayrollCustomComponent, PayrollPeriod from finance.payroll.components import get_payroll_component +from finance.payroll.scheduled_changes import next_payroll_period Employee = get_user_model() @@ -116,10 +117,6 @@ class PayrollComponentSerializer(serializers.Serializer): 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 @@ -186,34 +183,11 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): raise serializers.ValidationError( {"period_id": "Выберите исходный расчётный период."} ) - application_period = ( - PayrollPeriod.objects.filter(date_from__gt=source_period.date_from) - .order_by("date_from", "pk") - .first() - ) - if application_period is None: - raise serializers.ValidationError( - { - "period_id": ( - "Следующий расчётный период ещё не создан. " - "Сначала создайте его." - ) - } - ) - if application_period.status == "closed": - raise serializers.ValidationError( - { - "period_id": ( - "Следующий расчётный период уже закрыт. " - "Запланированное изменение создать нельзя." - ) - } - ) - attrs["scheduled_application_period"] = application_period + target_period = next_payroll_period(source_period) or source_period try: target = get_payroll_component( target_code, - period=application_period, + period=target_period, ) except ValueError as exc: raise serializers.ValidationError( @@ -231,9 +205,20 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): ) } ) + if ( + target_period.pk == source_period.pk + and target.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY + ): + raise serializers.ValidationError( + { + "scheduled_target_component_code": ( + "Колонка назначения должна быть доступна " + "в следующем периоде." + ) + } + ) else: attrs["scheduled_target_component_code"] = "" - attrs["scheduled_application_period"] = None return attrs diff --git a/backend/finance/migrations/0037_defer_scheduled_changes.py b/backend/finance/migrations/0037_defer_scheduled_changes.py new file mode 100644 index 00000000..956a9f89 --- /dev/null +++ b/backend/finance/migrations/0037_defer_scheduled_changes.py @@ -0,0 +1,33 @@ +from django.db import migrations + + +def remove_obsolete_application_periods(apps, schema_editor): + finance_settings = apps.get_model("finance", "FinanceSettings") + for settings in finance_settings.objects.all().iterator(): + overrides = dict(settings.payroll_component_overrides or {}) + changed = False + for code, override in overrides.items(): + if not isinstance(override, dict): + continue + if "scheduled_application_period_id" in override: + overrides[code] = dict(override) + overrides[code].pop("scheduled_application_period_id", None) + changed = True + if changed: + settings.payroll_component_overrides = overrides + settings.save(update_fields=["payroll_component_overrides"]) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0036_scheduled_component_changes")] + + operations = [ + migrations.RunPython( + remove_obsolete_application_periods, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="payrollcustomcomponent", + name="scheduled_application_period", + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index d4645029..3856be40 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1031,14 +1031,6 @@ class PayrollCustomComponent(models.Model): 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( @@ -1116,46 +1108,18 @@ class PayrollCustomComponent(models.Model): 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, + period=self.scope_period, ) except ValueError as exc: raise ValidationError( { "scheduled_target_component_code": ( - "Колонка назначения недоступна в периоде применения." + "Колонка назначения недоступна в начальном периоде." ) } ) from exc @@ -1170,10 +1134,7 @@ class PayrollCustomComponent(models.Model): 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 - ): + elif self.scheduled_target_component_code: raise ValidationError( { "scheduled_target_component_code": ( @@ -1247,12 +1208,6 @@ 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 diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 7c955085..617b2bc4 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -20,7 +20,6 @@ class PayrollComponentDefinition: is_custom: bool = False highlight_color: str = "" scheduled_target_component_code: str = "" - scheduled_application_period_id: int | None = None @property def id(self) -> str: @@ -195,10 +194,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: "scheduled_target_component_code", component.scheduled_target_component_code, ), - scheduled_application_period_id=override.get( - "scheduled_application_period_id", - component.scheduled_application_period_id, - ), ) ) @@ -207,7 +202,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: .select_related( "scope_period", "hidden_from_period", - "scheduled_application_period", ) .prefetch_related("excluded_periods") .order_by("display_order", "name", "id") @@ -218,11 +212,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: for component in builtins if component.scope_period_id is not None } - referenced_period_ids.update( - component.scheduled_application_period_id - for component in builtins - if component.scheduled_application_period_id is not None - ) period_dates = dict( PayrollPeriod.objects.filter(pk__in=referenced_period_ids).values_list( "pk", "date_from" @@ -251,14 +240,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: ) ) ) - and not ( - component.kind == PayrollComponentKind.SCHEDULED_CHANGE - and component.scheduled_application_period_id is not None - and period_dates.get(component.scheduled_application_period_id) - is not None - and period.date_from - >= period_dates[component.scheduled_application_period_id] - ) ] custom = [ component for component in custom if component.applies_to_period(period) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index bf6eb23a..9ed9a5d4 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -191,7 +191,6 @@ def _component_definition( is_custom=False, highlight_color="", scheduled_target_component_code="", - scheduled_application_period_id=None, ): return { "code": code, @@ -207,7 +206,6 @@ def _component_definition( "is_custom": is_custom, "highlight_color": highlight_color, "scheduled_target_component_code": scheduled_target_component_code, - "scheduled_application_period_id": scheduled_application_period_id, } @@ -414,7 +412,6 @@ def build_payroll_period_table(period: PayrollPeriod): 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 } diff --git a/backend/finance/payroll/scheduled_changes.py b/backend/finance/payroll/scheduled_changes.py index 3e1eb3b1..9211abc8 100644 --- a/backend/finance/payroll/scheduled_changes.py +++ b/backend/finance/payroll/scheduled_changes.py @@ -8,13 +8,106 @@ 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.enums import ApprovalStatus, InputSource, PayrollComponentKind from finance.models import PayrollCustomComponent, PayrollInputLine, PayrollPeriod from requests_app.enums import RequestStatus SCHEDULED_CHANGE_SOURCE_PREFIX = "payroll-scheduled-change:" +def next_payroll_period(source_period: PayrollPeriod, *, for_update: bool = False): + """Return the nearest later period in the same payroll timeline.""" + + periods = PayrollPeriod.objects.filter( + currency=source_period.currency, + date_from__gt=source_period.date_to, + ).order_by("date_from", "pk") + if for_update: + periods = periods.select_for_update() + return periods.first() + + +def materialize_scheduled_changes( + *, + source_period: PayrollPeriod, + target_period: PayrollPeriod, +) -> list[PayrollInputLine]: + """Apply source markers once when their immediately next period exists.""" + + next_period = next_payroll_period(source_period, for_update=True) + if next_period is None or next_period.pk != target_period.pk: + return [] + + from finance.payroll.components import get_payroll_component, get_payroll_components + + scheduled_components = { + component.code: component + for component in get_payroll_components(source_period) + if component.kind == PayrollComponentKind.SCHEDULED_CHANGE + } + if not scheduled_components: + return [] + + target_components = { + component.code: component for component in get_payroll_components(target_period) + } + source_lines = list( + PayrollInputLine.objects.select_for_update() + .select_related("created_by", "approved_by") + .filter( + period=source_period, + component_code__in=scheduled_components, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .order_by("pk") + ) + existing_refs = set( + PayrollInputLine.objects.filter( + source=InputSource.API, + source_ref__in=[ + f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}" for line in source_lines + ], + ).values_list("source_ref", flat=True) + ) + generated = [] + for source_line in source_lines: + source_ref = f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{source_line.pk}" + if source_ref in existing_refs: + continue + scheduled_component = scheduled_components[source_line.component_code] + target_code = scheduled_component.scheduled_target_component_code + target = target_components.get(target_code) + if target is None: + target = get_payroll_component(target_code, period=target_period) + if target.kind == PayrollComponentKind.SCHEDULED_CHANGE: + raise ValueError( + "Запланированное изменение не может изменять другой маркер." + ) + line = PayrollInputLine( + period=target_period, + employee_id=source_line.employee_id, + component_code=target_code, + amount=source_line.amount, + value_sign=source_line.value_sign, + reason=( + f"Применено из колонки «{scheduled_component.name}» " + f"периода {source_period.code}" + ), + status=source_line.status, + source=InputSource.API, + source_ref=source_ref, + include_in_carryover=False, + created_by=source_line.created_by, + approved_by=source_line.approved_by, + approved_at=source_line.approved_at, + self_approval_overridden=source_line.self_approval_overridden, + ) + line.full_clean() + line.save(force_insert=True) + generated.append(line) + return generated + + def is_generated_scheduled_change(line: PayrollInputLine) -> bool: return line.source_ref.startswith(SCHEDULED_CHANGE_SOURCE_PREFIX) diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 25ddb576..9873bd27 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -64,6 +64,8 @@ from .scheduled_changes import ( SCHEDULED_CHANGE_SOURCE_PREFIX, employee_is_eligible_for_scheduled_change, is_generated_scheduled_change, + materialize_scheduled_changes, + next_payroll_period, scheduled_component_for_period, target_cell_scheduled_delta, ) @@ -1988,21 +1990,10 @@ def clear_component_input_lines( 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 + if ( + scheduled_component is None + and validate_scheduled_result + and employee_is_eligible_for_scheduled_change(employee, period) ): scheduled_delta = target_cell_scheduled_delta( period=period, @@ -2043,6 +2034,16 @@ def clear_component_input_lines( ) .order_by("pk") ) + for target_period in {generated.period for generated in generated_lines}: + 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, + ) now = timezone.now() summary = {"draft": 0, "approved": 0, "total": len(lines)} for line in lines: @@ -2099,6 +2100,19 @@ def set_component_input_line_value( details={"component_code": component_code}, ) from exc scheduled_component = scheduled_component_for_period(component_code, period) + target_period = None + if scheduled_component is not None: + target_period = next_payroll_period(period, for_update=True) + if target_period is not None: + 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, + ) signed_amount = Decimal(amount) if signed_amount == 0: _operation_error( @@ -2157,29 +2171,16 @@ 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, - ) + if scheduled_component is not None and target_period is not None: try: - generated.full_clean() - generated.save(force_insert=True) + materialize_scheduled_changes( + source_period=period, + target_period=target_period, + ) + generated = PayrollInputLine.objects.get( + source=InputSource.API, + source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}", + ) except ValidationError as exc: raise PayrollOperationError( "INPUT_VALIDATION_FAILED", @@ -2346,11 +2347,9 @@ 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 - ) + target_period = next_payroll_period(period) + if target_period is None: + continue try: _ensure_period_accepts_approvals(target_period) except PayrollOperationError as exc: @@ -2492,18 +2491,17 @@ def approve_input_line( ) 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, - ) + target_period = next_payroll_period(period, for_update=True) + if target_period is not None: + 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, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index d04b3ec7..8fe0b251 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -214,7 +214,6 @@ def test_manager_can_customize_system_component_behavior( "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 index 1ce9e23e..94682f0b 100644 --- a/backend/tests/finance/test_payroll_scheduled_changes.py +++ b/backend/tests/finance/test_payroll_scheduled_changes.py @@ -152,7 +152,7 @@ def scheduled_workspace(user_factory, auth_client_factory): } -def test_scheduled_component_uses_the_immediately_next_period_automatically( +def test_scheduled_component_stores_a_rule_without_binding_a_future_period( scheduled_workspace, ): workspace = scheduled_workspace @@ -160,7 +160,6 @@ def test_scheduled_component_uses_the_immediately_next_period_automatically( name="Плановое изменение премии", source_period=workspace["periods"][7], ) - payload["scheduled_application_period_id"] = workspace["periods"][9].pk response = workspace["client"].post( api_url("admin-component-list"), payload, @@ -171,7 +170,7 @@ def test_scheduled_component_uses_the_immediately_next_period_automatically( assert component["kind"] == SCHEDULED_CHANGE_KIND assert component["scheduled_target_component_code"] == "BONUS" - assert component["scheduled_application_period_id"] == workspace["periods"][8].pk + assert "scheduled_application_period_id" not in component assert component["period_scope"] == "current_and_future" assert component["scope_period_id"] == workspace["periods"][7].pk assert component["include_in_point_base"] is False @@ -195,38 +194,70 @@ def test_scheduled_component_rejects_missing_target(scheduled_workspace): assert "scheduled_target_component_code" in response.json() -def test_scheduled_component_requires_a_next_period(scheduled_workspace): +def test_marker_waits_until_the_next_period_is_created(scheduled_workspace): workspace = scheduled_workspace - response = workspace["client"].post( - api_url("admin-component-list"), - component_payload( - name="Маркер без следующего периода", - source_period=workspace["periods"][9], - ), + client = workspace["client"] + employee = workspace["employee"] + source = workspace["periods"][9] + component = create_scheduled_component( + client, + name="Маркер без следующего периода", + source_period=source, + ) + saved = set_cell( + client, + period=source, + employee=employee, + component_code=component["code"], + amount="500.00", + ) + + assert saved.status_code == 201, saved.content + assert not PayrollInputLine.objects.filter( + source_ref__startswith="payroll-scheduled-change:" + ).exists() + + created = client.post( + api_url("admin-period-list"), + { + "code": "2026-10", + "name": "Октябрь 2026", + "date_from": "2026-10-01", + "date_to": "2026-10-31", + "pay_date": "2026-11-05", + "currency": "RUB", + }, format="json", ) - assert response.status_code == 400, response.content - assert "period_id" in response.json() + assert created.status_code == 201, created.content + target = PayrollPeriod.objects.get(pk=created.json()["id"]) + row = employee_row(period_table(client, target), employee) + assert row["component_amounts"]["BONUS"] == "500.00" + assert len(row["applied_scheduled_changes"]) == 1 -def test_scheduled_component_rejects_closed_application_period(scheduled_workspace): +def test_closed_next_period_rejects_marker_value(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"][7], - ), - format="json", + component = create_scheduled_component( + workspace["client"], + name="Маркер в закрытый период", + source_period=workspace["periods"][7], + ) + response = set_cell( + workspace["client"], + period=workspace["periods"][7], + employee=workspace["employee"], + component_code=component["code"], + amount="500.00", ) - assert response.status_code == 400, response.content - assert "period_id" in response.json() + assert response.status_code == 409, response.content + assert response.json()["code"] == "TARGET_PERIOD_INPUTS_LOCKED" def test_empty_system_component_can_be_converted_to_a_scheduled_marker( @@ -281,8 +312,8 @@ def test_empty_system_component_can_be_converted_to_a_scheduled_marker( "source_component_name": "Будущее изменение премии", } ] - assert all( - component["code"] != "ADVANCE" + assert any( + component["code"] == "ADVANCE" for component in period_table(client, target)["component_columns"] ) @@ -525,7 +556,7 @@ def test_multiple_marker_columns_are_summed_in_the_next_period( ) -def test_marker_column_is_visible_only_in_its_source_period( +def test_marker_column_is_available_from_its_source_period_onward( scheduled_workspace, ): workspace = scheduled_workspace @@ -546,10 +577,10 @@ def test_marker_column_is_visible_only_in_its_source_period( assert component["code"] not in { column["code"] for column in period_table(client, previous)["component_columns"] } - assert component["code"] not in { + assert component["code"] in { column["code"] for column in period_table(client, target)["component_columns"] } - assert component["code"] not in { + assert component["code"] in { column["code"] for column in period_table(client, following)["component_columns"] } @@ -586,7 +617,7 @@ def test_reentering_same_cell_replaces_its_net_value_instead_of_stacking( assert len(target_row["applied_scheduled_changes"]) == 1 -def test_marker_value_cannot_be_entered_in_or_after_application_period( +def test_each_period_marker_changes_only_its_immediately_next_period( scheduled_workspace, ): workspace = scheduled_workspace @@ -594,22 +625,28 @@ def test_marker_value_cannot_be_entered_in_or_after_application_period( employee = workspace["employee"] source = workspace["periods"][7] target = workspace["periods"][8] + following = workspace["periods"][9] component = create_scheduled_component( client, - name="Маркер только до назначения", + name="Повторно используемый маркер", source_period=source, ) - 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" + saved = set_cell( + client, + period=target, + 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"][component["code"]] == "500.00" + assert "BONUS" not in target_row["component_amounts"] + following_row = employee_row(period_table(client, following), employee) + assert following_row["component_amounts"]["BONUS"] == "500.00" + assert len(following_row["applied_scheduled_changes"]) == 1 @pytest.mark.parametrize("existing_mode", ["skip", "replace"]) @@ -723,10 +760,11 @@ def test_adjusted_value_copies_normally_later_without_copying_marker_badge( 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 { + assert component["code"] in { column["code"] for column in period_table(client, following)["component_columns"] } + assert component["code"] not in following_row["component_amounts"] def test_editing_and_clearing_marker_recalculates_open_target_once( @@ -779,20 +817,21 @@ def test_editing_and_clearing_marker_recalculates_open_target_once( assert row["applied_scheduled_changes"] == [] -def test_automatic_copy_carries_effective_value_but_not_marker(scheduled_workspace): +def test_period_creation_copies_then_applies_pending_marker_once( + scheduled_workspace, +): workspace = scheduled_workspace client = workspace["client"] manager = workspace["manager"] employee = workspace["employee"] - source = workspace["periods"][7] - applied = workspace["periods"][8] + source = workspace["periods"][8] component = create_scheduled_component( client, name="Маркер перед автопереносом", source_period=source, ) PayrollInputLine.objects.create( - period=applied, + period=source, employee=employee, component_code="BONUS", amount="1000.00", @@ -834,11 +873,12 @@ def test_automatic_copy_carries_effective_value_but_not_marker(scheduled_workspa 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 { + assert len(row["applied_scheduled_changes"]) == 1 + assert component["code"] in { column["code"] for column in period_table(client, following)["component_columns"] } + assert component["code"] not in row["component_amounts"] def test_marker_is_not_applied_to_employee_dismissed_before_target( @@ -1000,7 +1040,7 @@ def test_target_column_cannot_be_hidden_while_a_marker_references_it( assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES" -def test_target_column_cannot_be_scoped_away_from_application_period( +def test_target_column_cannot_be_scoped_away_from_future_periods( scheduled_workspace, ): workspace = scheduled_workspace @@ -1042,7 +1082,6 @@ def test_target_column_cannot_be_scoped_away_from_application_period( "period_scope": "current_only", "period_id": source.pk, "scheduled_target_component_code": "", - "scheduled_application_period_id": None, }, format="json", ) @@ -1051,7 +1090,7 @@ def test_target_column_cannot_be_scoped_away_from_application_period( assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES" -def test_marker_rejects_target_column_unavailable_in_application_period( +def test_marker_rejects_target_column_unavailable_in_the_next_period( scheduled_workspace, ): workspace = scheduled_workspace diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 3c199ce0..556c837c 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -2227,7 +2227,6 @@ export function PayrollAdminWorkspace({ Promise; onDelete?: (mode: "current" | "current_and_future") => Promise; @@ -796,19 +787,6 @@ function PayrollComponentSettingsModal({ }; }, [onClose, pending]); - const currentPeriod = periods.find((item) => item.id === currentPeriodId); - const nextPeriod = periods - .filter( - (item) => - currentPeriod && - new Date(item.date_from).getTime() > - new Date(currentPeriod.date_from).getTime(), - ) - .sort( - (left, right) => - new Date(left.date_from).getTime() - - new Date(right.date_from).getTime() || left.id - right.id, - )[0]; const scheduledTargets = components.filter( (item) => item.code !== component?.code && item.kind !== "scheduled_change", ); @@ -825,14 +803,6 @@ function PayrollComponentSettingsModal({ setError("Выберите колонку, значение которой нужно изменить."); return; } - if (isScheduled && !nextPeriod) { - setError("Следующий расчётный период ещё не создан."); - return; - } - if (isScheduled && nextPeriod.status === "closed") { - setError("Следующий расчётный период уже закрыт."); - return; - } if (!isScheduled && pointBaseAssessment.negative.length) { setError( "Сохранение заблокировано: настройка создаёт отрицательную балльную базу.", @@ -898,14 +868,10 @@ function PayrollComponentSettingsModal({ is_custom: component?.is_custom ?? true, highlight_color: highlightColor, scheduled_target_component_code: isScheduled ? scheduledTargetCode : "", - scheduled_application_period_id: isScheduled - ? nextPeriod?.id || null - : null, }; const calculationPreview = componentCalculationHelp( previewComponent, components, - periods, ); const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, { code: component?.code || "PREVIEW", @@ -1021,25 +987,12 @@ function PayrollComponentSettingsModal({
Когда применится - {nextPeriod ? ( -

- Автоматически в следующем периоде —{" "} - - {nextPeriod.name || nextPeriod.code} - - - {getPayrollPeriodRange(nextPeriod)} - {nextPeriod.status === "closed" - ? " · период уже закрыт" - : ""} - -

- ) : ( -

- Следующий расчётный период ещё не создан. Сначала создайте - его. -

- )} +

+ Автоматически при создании следующего расчётного периода +

+

+ Создавать следующий период заранее не требуется. +

Маркер допускает плюс и минус, не участвует в текущем расчёте и @@ -1548,18 +1501,13 @@ function calculationHelps( function componentCalculationHelp( component: PayrollPeriodTableComponent, components: PayrollPeriodTableComponent[] = [], - periods: PayrollAdminPeriod[] = [], ): CalculationHelp { if (component.kind === "scheduled_change") { const target = components.find( (item) => item.code === component.scheduled_target_component_code, ); - const applicationPeriod = periods.find( - (item) => item.id === component.scheduled_application_period_id, - ); const targetLabel = target?.label || "выбранная колонка"; - const periodLabel = - applicationPeriod?.name || applicationPeriod?.code || "следующий период"; + const periodLabel = "следующий период"; return { title: component.label, logic: `Это внутренний маркер. В текущем периоде он не меняет расчёт и не показывается сотруднику. В периоде «${periodLabel}» сумма один раз прибавится к колонке «${targetLabel}»; отрицательная сумма уменьшит её. Несколько маркеров складываются.`, @@ -1980,7 +1928,6 @@ type CompactPeriodPanel = "summary" | "table" | "help"; export function PayrollPeriodTableView({ data, - periods = [], loading, error, search, @@ -2003,7 +1950,6 @@ export function PayrollPeriodTableView({ compact = false, }: { data: PayrollPeriodTable | null; - periods?: PayrollAdminPeriod[]; loading: boolean; error: string | null; search: string; @@ -2423,11 +2369,7 @@ export function PayrollPeriodTableView({ > { @@ -3847,8 +3789,6 @@ export function PayrollPeriodTableView({ component={editingComponent} rows={data.rows} components={data.component_columns} - periods={periods} - currentPeriodId={data.period_id} currency={data.currency} onSave={(payload) => editingComponent && onUpdateComponent diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 55b2446b..f026f25c 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -402,7 +402,6 @@ export type PayrollPeriodTableComponent = { is_custom: boolean; highlight_color: PayrollColumnHighlightColor; scheduled_target_component_code: string; - scheduled_application_period_id: number | null; }; export type PayrollCellCommentAuthor = { @@ -637,7 +636,6 @@ export type PayrollComponent = { is_custom: boolean; highlight_color: PayrollColumnHighlightColor; scheduled_target_component_code: string; - scheduled_application_period_id: number | null; }; export type PayrollColumnHighlightColor =