From abef43136b52ee1113e37f3e8fa81bb89970b9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 19 Oct 2026 10:47:57 +0300 Subject: [PATCH] Apply automatic payroll input transfer when closing periods --- backend/api/v1/finance/admin/input_copy.py | 278 +--------- .../finance/admin/input_views/input_lines.py | 31 +- .../v1/finance/admin/period_views/catalog.py | 36 +- .../finance/payroll_serializers/commands.py | 16 +- .../v1/finance/payroll_serializers/periods.py | 2 + .../0042_input_copy_on_period_closure.py | 48 ++ backend/finance/models.py | 15 +- .../finance/payroll/automatic_input_copy.py | 142 +++++ backend/finance/payroll/input_copy.py | 292 +++++++++++ backend/finance/payroll/periods.py | 112 ++++ backend/finance/payroll/services.py | 11 + backend/finance/tasks.py | 93 +--- .../finance/test_automatic_input_copy.py | 489 ++++++++++++++++++ .../test_automatic_input_copy_migration.py | 84 +++ .../tests/finance/test_payroll_admin_api.py | 14 +- .../finance/test_payroll_scheduled_changes.py | 14 +- .../finance/admin/PayrollAdminWorkspace.tsx | 37 +- .../finance/admin/PayrollInputCopyModal.tsx | 25 +- frontend/src/lib/api/finance.ts | 7 +- .../src/lib/payroll-period-closure.test.js | 38 ++ 20 files changed, 1342 insertions(+), 442 deletions(-) create mode 100644 backend/finance/migrations/0042_input_copy_on_period_closure.py create mode 100644 backend/finance/payroll/automatic_input_copy.py create mode 100644 backend/finance/payroll/input_copy.py create mode 100644 backend/finance/payroll/periods.py create mode 100644 backend/tests/finance/test_automatic_input_copy.py create mode 100644 backend/tests/finance/test_automatic_input_copy_migration.py create mode 100644 frontend/src/lib/payroll-period-closure.test.js diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 199fd4ad..a07bead6 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -3,22 +3,19 @@ from decimal import Decimal from django.db.models import Count -from django.utils import timezone 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, PayrollComponentKind from finance.models import ( - PayrollAuditEvent, PayrollInputCopyConfiguration, 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 finance.payroll.input_copy import ( + _collapse_scheduled_effective_cells, + _copyable_input_lines, + _employee_active_for_period, ) from ..admin_serializers import ( @@ -26,7 +23,6 @@ from ..admin_serializers import ( PayrollEmployeeMiniSerializer, PayrollPeriodSerializer, ) -from .common import _audit_api_mutation def _validate_input_copy_periods(target_period, source_period): @@ -46,95 +42,13 @@ 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) - if component.kind != PayrollComponentKind.SCHEDULED_CHANGE - } - if target_period is not None: - component_codes &= { - component.code - for component in get_payroll_components(target_period) - if component.kind != PayrollComponentKind.SCHEDULED_CHANGE - } - return ( - PayrollInputLine.objects.filter( - period=source_period, - employee__is_hidden_in_ui=False, - status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), - component_code__in=component_codes, - ) - .exclude( - source=InputSource.API, - source_ref__startswith="payroll-carryover:", - ) - .select_related( - "employee", - "employee__position", - "relates_to_period", - ) - .prefetch_related( - "employee__departments_links__department", - "employee__actions__source_request", - ) - .order_by("employee_id", "component_code", "id") - ) - - -def _employee_active_for_period(employee, period): - if not employee.is_active: - return False - actions = employee._prefetched_objects_cache.get("actions") - state = resolve_employee_personnel_state( - employee, - period.date_to, - actions=actions, - ) - 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() + configuration = ( + PayrollInputCopyConfiguration.objects.filter( + auto_apply_on_period_closure=True + ).first() + or PayrollInputCopyConfiguration.objects.filter(owner=actor).first() + ) if configuration is None: return { "saved": False, @@ -143,7 +57,7 @@ def _input_copy_configuration_payload(actor): "employee_scope": "active", "employee_ids": [], "existing_mode": "skip", - "auto_apply_on_period_creation": False, + "auto_apply_on_period_closure": False, } visible_employee_ids = list( Employee.objects.visible_in_ui() @@ -157,7 +71,7 @@ def _input_copy_configuration_payload(actor): "employee_scope": configuration.employee_scope, "employee_ids": visible_employee_ids, "existing_mode": configuration.existing_mode, - "auto_apply_on_period_creation": (configuration.auto_apply_on_period_creation), + "auto_apply_on_period_closure": configuration.auto_apply_on_period_closure, } @@ -252,171 +166,3 @@ def _input_copy_preview(target_period, source_period, *, actor): "employees": employees, "configuration": _input_copy_configuration_payload(actor), } - - -def _apply_input_copy(*, target_period, source_period, actor, data): - copy_prefix = ( - f"payroll-period-copy:{target_period.pk}:{source_period.pk}:" - f"{data['idempotency_key']}:" - ) - existing = list( - PayrollInputLine.objects.filter( - period=target_period, - source_ref__startswith=copy_prefix, - ) - .order_by("id") - .values_list("id", flat=True) - ) - if existing: - return { - "source_period_id": source_period.pk, - "target_period_id": target_period.pk, - "summary": { - "created": len(existing), - "employees": PayrollInputLine.objects.filter(pk__in=existing) - .values("employee_id") - .distinct() - .count(), - "skipped": 0, - "replaced_cells": 0, - "voided": 0, - }, - "record_ids": existing, - "idempotent_replay": True, - } - lines = list( - _copyable_input_lines( - source_period, - 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] - elif data["employee_scope"] == "active": - lines = [ - line - for line in lines - if _employee_active_for_period(line.employee, target_period) - ] - selected_cells = {(line.employee_id, line.component_code) for line in lines} - existing_lines = [] - if selected_cells: - possible_existing_lines = list( - PayrollInputLine.objects.select_for_update() - .filter( - period=target_period, - employee_id__in={employee_id for employee_id, _ in selected_cells}, - component_code__in={ - component_code for _, component_code in selected_cells - }, - status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), - ) - .order_by("pk") - ) - existing_lines = [ - 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:") - ) - ] - occupied_cells = { - (line.employee_id, line.component_code) for line in existing_lines - } - skipped = 0 - replaced_cells = 0 - voided = 0 - if data["existing_mode"] == "skip" and occupied_cells: - kept_lines = [] - for line in lines: - if (line.employee_id, line.component_code) in occupied_cells: - skipped += 1 - else: - kept_lines.append(line) - lines = kept_lines - elif data["existing_mode"] == "replace" and existing_lines: - replaced_cells = len(occupied_cells) - voided = len(existing_lines) - now = timezone.now() - for line in existing_lines: - previous_status = line.status - line.status = ApprovalStatus.VOIDED - line.voided_by = actor - line.voided_at = now - line.void_reason = ( - f"Заменено переносом записей из периода {source_period.code}" - ) - line.save( - update_fields=[ - "status", - "voided_by", - "voided_at", - "void_reason", - ] - ) - _audit_api_mutation( - actor=actor, - action="payroll.input_line_voided_from_period_copy", - instance=line, - period=target_period, - metadata={ - "previous_status": previous_status, - "source_period_id": source_period.pk, - }, - ) - records = PayrollInputLine.objects.bulk_create( - [ - PayrollInputLine( - period=target_period, - 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}{getattr(line, '_copy_source_token', line.pk)}" - ), - include_in_carryover=line.include_in_carryover, - created_by=actor, - ) - for line in lines - ] - ) - PayrollAuditEvent.objects.bulk_create( - [ - PayrollAuditEvent( - actor=actor, - action="payroll.input_line_draft_created", - object_type=PayrollInputLine._meta.label_lower, - object_id=str(record.pk), - period=target_period, - metadata={ - "channel": "admin_api", - "copied_from_period_id": source_period.pk, - "copied_from_input_line_id": source.pk, - }, - ) - for record, source in zip(records, lines, strict=True) - ] - ) - return { - "source_period_id": source_period.pk, - "target_period_id": target_period.pk, - "summary": { - "created": len(records), - "employees": len({record.employee_id for record in records}), - "skipped": skipped, - "replaced_cells": replaced_cells, - "voided": voided, - }, - "record_ids": [record.pk for record in records], - "idempotent_replay": False, - } 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 17f51bc4..c143235b 100644 --- a/backend/api/v1/finance/admin/input_views/input_lines.py +++ b/backend/api/v1/finance/admin/input_views/input_lines.py @@ -15,10 +15,14 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response from finance.models import ( - PayrollInputCopyConfiguration, PayrollInputLine, PayrollPeriod, ) +from finance.payroll.automatic_input_copy import ( + lock_automatic_input_copy_policy, + save_input_copy_configuration, +) +from finance.payroll.input_copy import apply_input_copy as _apply_input_copy from finance.payroll.scheduled_changes import ( SCHEDULED_CHANGE_SOURCE_PREFIX, materialize_scheduled_changes, @@ -44,7 +48,6 @@ from ..common import ( _optional_int, ) from ..input_copy import ( - _apply_input_copy, _input_copy_preview, _validate_input_copy_periods, ) @@ -169,6 +172,7 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): command = CopyPayrollInputLinesCommandSerializer(data=request.data) command.is_valid(raise_exception=True) data = command.validated_data + lock_automatic_input_copy_policy() target_period = _lock_period_accepting_draft_edits(pk) if target_period.lock_version != data["expected_period_lock_version"]: _conflict( @@ -182,21 +186,14 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView): pk=data["source_period_id"], ) _validate_input_copy_periods(target_period, source_period) - if data["remember_configuration"]: - PayrollInputCopyConfiguration.objects.update_or_create( - owner=request.user, - defaults={ - "component_codes": data["component_codes"], - "employee_scope": data["employee_scope"], - "employee_ids": data["employee_ids"], - "existing_mode": data["existing_mode"], - "auto_apply_on_period_creation": data[ - "auto_apply_on_period_creation" - ], - }, - ) - else: - PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete() + save_input_copy_configuration( + actor=request.user, + data=data, + update_automatic_policy=( + "auto_apply_on_period_closure" in request.data + or "auto_apply_on_period_creation" in request.data + ), + ) result = _apply_input_copy( target_period=target_period, source_period=source_period, diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index bb6a3dfe..932d9383 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -7,7 +7,6 @@ access to payroll data. from __future__ import annotations -import uuid from collections import defaultdict from django.core.exceptions import ValidationError as DjangoValidationError @@ -22,11 +21,11 @@ from finance.enums import ApprovalStatus, PayrollComponentKind, PayrollPeriodSta from finance.models import ( FinanceSettings, PayrollCustomComponent, - PayrollInputCopyConfiguration, PayrollInputLine, PayrollPeriod, ) from finance.payroll.adapter import point_base_accruals_for_employees +from finance.payroll.automatic_input_copy import lock_automatic_input_copy_policy from finance.payroll.components import ( get_payroll_components, ) @@ -52,7 +51,6 @@ from ..common import ( _conflict, _optional_int, ) -from ..input_copy import _apply_input_copy def _reject_closed_component_period(period): @@ -429,6 +427,8 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) serializer = PayrollPeriodWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) + # Serialize creation with closure before taking either period's lock. + lock_automatic_input_copy_policy() period_values = dict(serializer.validated_data) period_values.pop("expected_lock_version", None) period = PayrollPeriod( @@ -464,41 +464,13 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView): .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 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, ) payload = PayrollPeriodSerializer(period).data - payload["automatic_input_copy"] = automatic_copy + payload["automatic_input_copy"] = None return Response( payload, status=status.HTTP_201_CREATED, diff --git a/backend/api/v1/finance/payroll_serializers/commands.py b/backend/api/v1/finance/payroll_serializers/commands.py index 5d70d447..8e1257c6 100644 --- a/backend/api/v1/finance/payroll_serializers/commands.py +++ b/backend/api/v1/finance/payroll_serializers/commands.py @@ -78,9 +78,19 @@ class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): expected_period_lock_version = serializers.IntegerField(min_value=0) idempotency_key = serializers.UUIDField() remember_configuration = serializers.BooleanField(default=False) - auto_apply_on_period_creation = serializers.BooleanField(default=False) + auto_apply_on_period_closure = serializers.BooleanField(default=False) + # Accept cached older clients, but only the new closure trigger is used. + auto_apply_on_period_creation = serializers.BooleanField( + required=False, write_only=True + ) def validate(self, attrs): + legacy_auto = attrs.pop("auto_apply_on_period_creation", None) + if ( + legacy_auto is not None + and "auto_apply_on_period_closure" not in self.initial_data + ): + attrs["auto_apply_on_period_closure"] = legacy_auto if not attrs.get("component_codes"): raise serializers.ValidationError( {"component_codes": "Выберите хотя бы один вид операции."} @@ -94,12 +104,12 @@ class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): if attrs["employee_scope"] != "selected": attrs["employee_ids"] = [] if ( - attrs["auto_apply_on_period_creation"] + attrs["auto_apply_on_period_closure"] and not attrs["remember_configuration"] ): raise serializers.ValidationError( { - "auto_apply_on_period_creation": ( + "auto_apply_on_period_closure": ( "Для автоприменения сначала сохраните конфигурацию." ) } diff --git a/backend/api/v1/finance/payroll_serializers/periods.py b/backend/api/v1/finance/payroll_serializers/periods.py index c0040c85..02931237 100644 --- a/backend/api/v1/finance/payroll_serializers/periods.py +++ b/backend/api/v1/finance/payroll_serializers/periods.py @@ -28,6 +28,7 @@ Employee = get_user_model() class PayrollPeriodSerializer(serializers.ModelSerializer): + automatic_input_copy = serializers.JSONField(read_only=True, default=None) status_label = serializers.CharField(source="get_status_display", read_only=True) current_run_id = serializers.IntegerField(read_only=True, allow_null=True) created_by_id = serializers.IntegerField(read_only=True) @@ -45,6 +46,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer): "status", "status_label", "current_run_id", + "automatic_input_copy", "balance_rules_version", "lock_version", "created_by_id", diff --git a/backend/finance/migrations/0042_input_copy_on_period_closure.py b/backend/finance/migrations/0042_input_copy_on_period_closure.py new file mode 100644 index 00000000..4eb258dd --- /dev/null +++ b/backend/finance/migrations/0042_input_copy_on_period_closure.py @@ -0,0 +1,48 @@ +"""Move the saved automatic policy to closure without touching payroll data.""" + +from django.db import migrations, models + + +def keep_latest_automatic_policy(apps, schema_editor): + Configuration = apps.get_model("finance", "PayrollInputCopyConfiguration") + enabled = Configuration.objects.filter(auto_apply_on_period_closure=True).order_by( + "-updated_at", "-pk" + ) + latest = enabled.values_list("pk", flat=True).first() + if latest is not None: + enabled.exclude(pk=latest).update(auto_apply_on_period_closure=False) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0041_salary_participation_and_balance_rules")] + operations = [ + # Keep the physical column compatible with the currently serving release. + # Only the Python field name and its semantics change in this migration. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RenameField( + model_name="payrollinputcopyconfiguration", + old_name="auto_apply_on_period_creation", + new_name="auto_apply_on_period_closure", + ), + migrations.AlterField( + model_name="payrollinputcopyconfiguration", + name="auto_apply_on_period_closure", + field=models.BooleanField( + default=False, + db_column="auto_apply_on_period_creation", + verbose_name="Автоматически применять при закрытии периода", + ), + ), + ], + ), + migrations.RunPython(keep_latest_automatic_policy, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="payrollinputcopyconfiguration", + constraint=models.UniqueConstraint( + fields=("auto_apply_on_period_closure",), + condition=models.Q(auto_apply_on_period_closure=True), + name="pay_input_copy_single_auto", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 11bc34dd..af28abd1 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -1542,7 +1542,7 @@ class VacationPayInstruction(models.Model): class PayrollInputCopyConfiguration(models.Model): - """Per-operator defaults for copying input lines between payroll periods.""" + """Operator copy defaults; one also defines the shared automatic policy.""" owner = models.OneToOneField( settings.AUTH_USER_MODEL, @@ -1568,15 +1568,24 @@ class PayrollInputCopyConfiguration(models.Model): choices=(("skip", "Пропустить"), ("replace", "Заменить")), default="skip", ) - auto_apply_on_period_creation = models.BooleanField( - "Автоматически применять при создании периода", + auto_apply_on_period_closure = models.BooleanField( + "Автоматически применять при закрытии периода", default=False, + # Blue-green deployment keeps the previous release online during migrate. + db_column="auto_apply_on_period_creation", ) updated_at = models.DateTimeField("Обновлена", auto_now=True) class Meta: verbose_name = "Конфигурация переноса зарплатных записей" verbose_name_plural = "Конфигурации переноса зарплатных записей" + constraints = [ + models.UniqueConstraint( + fields=["auto_apply_on_period_closure"], + condition=Q(auto_apply_on_period_closure=True), + name="pay_input_copy_single_auto", + ) + ] def __str__(self): return f"Перенос зарплатных записей: {self.owner_id}" diff --git a/backend/finance/payroll/automatic_input_copy.py b/backend/finance/payroll/automatic_input_copy.py new file mode 100644 index 00000000..2ff8be26 --- /dev/null +++ b/backend/finance/payroll/automatic_input_copy.py @@ -0,0 +1,142 @@ +"""A single saved automatic policy, applied transactionally on month closure.""" + +import uuid +from datetime import date + +from django.core.exceptions import ValidationError +from django.db import connection, transaction + +from finance.enums import PayrollPeriodStatus +from finance.models import ( + FinanceSettings, + PayrollAuditEvent, + PayrollInputCopyConfiguration, + PayrollPeriod, +) +from finance.payroll.exceptions import PayrollOperationError +from finance.payroll.input_copy import apply_input_copy +from finance.payroll.periods import ensure_calendar_payroll_period +from finance.payroll.scheduled_changes import materialize_scheduled_changes + + +def lock_automatic_input_copy_policy(): + """Take this lock before period locks in both configuration and close paths.""" + if connection.vendor == "postgresql": + # A transaction-scoped advisory lock also works before the settings row + # exists. Do not depend on racing first-time get_solo() inserts. + with connection.cursor() as cursor: + cursor.execute( + "SELECT pg_advisory_xact_lock(%s, %s)", [0x50415952, 0x434F5059] + ) + return + settings = FinanceSettings.objects.first() + if settings is None: + settings, _ = FinanceSettings.objects.get_or_create(pk=1) + FinanceSettings.objects.select_for_update().get(pk=settings.pk) + + +def save_input_copy_configuration(*, actor, data, update_automatic_policy=True): + """The caller holds the policy lock and performs the manual copy atomically.""" + if update_automatic_policy: + PayrollInputCopyConfiguration.objects.filter( + auto_apply_on_period_closure=True + ).update(auto_apply_on_period_closure=False) + if not data["remember_configuration"]: + PayrollInputCopyConfiguration.objects.filter(owner=actor).delete() + return + defaults = { + field: data[field] + for field in ( + "component_codes", + "employee_scope", + "employee_ids", + "existing_mode", + ) + } + # A serializer default is not an explicit request to switch automation off. + # Omitting this field also preserves it on an existing owner's configuration. + if update_automatic_policy: + defaults["auto_apply_on_period_closure"] = data["auto_apply_on_period_closure"] + PayrollInputCopyConfiguration.objects.update_or_create( + owner=actor, + defaults=defaults, + ) + + +@transaction.atomic +def copy_inputs_after_period_close(*, source_period, actor): + """Only called in the closing transaction, with policy and source locked.""" + configuration = PayrollInputCopyConfiguration.objects.filter( + auto_apply_on_period_closure=True + ).first() + if configuration is None: + return None + if source_period.status != PayrollPeriodStatus.CLOSED: + raise PayrollOperationError( + "SOURCE_PERIOD_NOT_CLOSED", + "Автоперенос выполняется только при закрытии периода.", + ) + year, month = source_period.date_to.year, source_period.date_to.month + target_start = date(year + (month == 12), 1 if month == 12 else month + 1, 1) + try: + target, created = ensure_calendar_payroll_period( + target_start, + currency=source_period.currency, + actor=actor, + channel="period_closure", + metadata={"source_period_id": source_period.pk}, + ) + except ValidationError as exc: + raise PayrollOperationError( + "NEXT_PERIOD_CONFLICT", + "Не удалось создать следующий месяц для автопереноса. Проверьте даты и валюту существующих периодов.", + ) from exc + target = PayrollPeriod.objects.select_for_update().get(pk=target.pk) + if target.status != PayrollPeriodStatus.OPEN or target.current_run_id is not None: + raise PayrollOperationError( + "NEXT_PERIOD_INPUTS_LOCKED", + "Следующий период уже рассчитан или закрыт. Его данные не изменены. Отключите автоматический перенос, чтобы закрыть исходный месяц; при необходимости выполните перенос вручную после возврата следующего периода на исправление.", + details={"target_period_id": target.pk, "target_period_code": target.code}, + ) + data = { + field: getattr(configuration, field) + for field in ( + "component_codes", + "employee_scope", + "employee_ids", + "existing_mode", + ) + } + data["idempotency_key"] = uuid.uuid5( + uuid.NAMESPACE_URL, f"eusrr:payroll-close-copy:{source_period.pk}:{target.pk}" + ) + result = apply_input_copy( + target_period=target, + source_period=source_period, + actor=actor, + data=data, + approved_only=True, + channel="period_closure", + ) + materialize_scheduled_changes(source_period=source_period, target_period=target) + result.update( + target_period_code=target.code, + target_period_created=created, + configuration_owner_id=configuration.owner_id, + ) + PayrollAuditEvent.objects.create( + actor=actor, + action="payroll.period_input_copy_auto_applied", + object_type=source_period._meta.label_lower, + object_id=str(source_period.pk), + period=source_period, + metadata={ + "channel": "period_closure", + "trigger": "period_closed", + "target_period_id": target.pk, + "target_period_created": created, + "configuration_owner_id": configuration.owner_id, + **result["summary"], + }, + ) + return result diff --git a/backend/finance/payroll/input_copy.py b/backend/finance/payroll/input_copy.py new file mode 100644 index 00000000..9c4e5092 --- /dev/null +++ b/backend/finance/payroll/input_copy.py @@ -0,0 +1,292 @@ +"""Shared payroll input-copy service, independent of HTTP adapters.""" + +from decimal import Decimal + +from django.utils import timezone + +from employees.constants import ACTION_DISMISSED +from employees.services.personnel_state import resolve_employee_personnel_state +from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind +from finance.models import PayrollAuditEvent, 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, +) + + +def _audit_copy_mutation(*, actor, action, instance, period, metadata): + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=instance._meta.label_lower, + object_id=str(instance.pk), + period=period, + metadata=metadata, + ) + + +def _copyable_input_lines(source_period, *, target_period=None): + component_codes = { + 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) + if component.kind != PayrollComponentKind.SCHEDULED_CHANGE + } + return ( + PayrollInputLine.objects.filter( + period=source_period, + employee__is_hidden_in_ui=False, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + component_code__in=component_codes, + ) + .exclude( + source=InputSource.API, + source_ref__startswith="payroll-carryover:", + ) + .select_related( + "employee", + "employee__position", + "relates_to_period", + ) + .prefetch_related( + "employee__departments_links__department", + "employee__actions__source_request", + ) + .order_by("employee_id", "component_code", "id") + ) + + +def _employee_active_for_period(employee, period): + if not employee.is_active: + return False + actions = employee._prefetched_objects_cache.get("actions") + state = resolve_employee_personnel_state( + employee, + period.date_to, + actions=actions, + ) + 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 apply_input_copy( + *, + target_period, + source_period, + actor, + data, + approved_only=False, + channel="admin_api", +): + copy_prefix = ( + f"payroll-period-copy:{target_period.pk}:{source_period.pk}:" + f"{data['idempotency_key']}:" + ) + existing = list( + PayrollInputLine.objects.filter( + period=target_period, + source_ref__startswith=copy_prefix, + ) + .order_by("id") + .values_list("id", flat=True) + ) + if existing: + return { + "source_period_id": source_period.pk, + "target_period_id": target_period.pk, + "summary": { + "created": len(existing), + "employees": PayrollInputLine.objects.filter(pk__in=existing) + .values("employee_id") + .distinct() + .count(), + "skipped": 0, + "replaced_cells": 0, + "voided": 0, + }, + "record_ids": existing, + "idempotent_replay": True, + } + lines = list( + _copyable_input_lines( + source_period, + target_period=target_period, + ).filter(component_code__in=data["component_codes"]) + ) + if approved_only: + lines = [line for line in lines if line.status == ApprovalStatus.APPROVED] + 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] + elif data["employee_scope"] == "active": + lines = [ + line + for line in lines + if _employee_active_for_period(line.employee, target_period) + ] + selected_cells = {(line.employee_id, line.component_code) for line in lines} + existing_lines = [] + if selected_cells: + possible_existing_lines = list( + PayrollInputLine.objects.select_for_update() + .filter( + period=target_period, + employee_id__in={employee_id for employee_id, _ in selected_cells}, + component_code__in={ + component_code for _, component_code in selected_cells + }, + status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), + ) + .order_by("pk") + ) + existing_lines = [ + 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:") + ) + ] + occupied_cells = { + (line.employee_id, line.component_code) for line in existing_lines + } + skipped = 0 + replaced_cells = 0 + voided = 0 + if data["existing_mode"] == "skip" and occupied_cells: + kept_lines = [] + for line in lines: + if (line.employee_id, line.component_code) in occupied_cells: + skipped += 1 + else: + kept_lines.append(line) + lines = kept_lines + elif data["existing_mode"] == "replace" and existing_lines: + replaced_cells = len(occupied_cells) + voided = len(existing_lines) + now = timezone.now() + for line in existing_lines: + previous_status = line.status + line.status = ApprovalStatus.VOIDED + line.voided_by = actor + line.voided_at = now + line.void_reason = ( + f"Заменено переносом записей из периода {source_period.code}" + ) + line.save( + update_fields=[ + "status", + "voided_by", + "voided_at", + "void_reason", + ] + ) + _audit_copy_mutation( + actor=actor, + action="payroll.input_line_voided_from_period_copy", + instance=line, + period=target_period, + metadata={ + "channel": channel, + "previous_status": previous_status, + "source_period_id": source_period.pk, + }, + ) + records = PayrollInputLine.objects.bulk_create( + [ + PayrollInputLine( + period=target_period, + 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}{getattr(line, '_copy_source_token', line.pk)}" + ), + include_in_carryover=line.include_in_carryover, + created_by=actor, + ) + for line in lines + ] + ) + PayrollAuditEvent.objects.bulk_create( + [ + PayrollAuditEvent( + actor=actor, + action="payroll.input_line_draft_created", + object_type=PayrollInputLine._meta.label_lower, + object_id=str(record.pk), + period=target_period, + metadata={ + "channel": channel, + "copied_from_period_id": source_period.pk, + "copied_from_input_line_id": source.pk, + }, + ) + for record, source in zip(records, lines, strict=True) + ] + ) + return { + "source_period_id": source_period.pk, + "target_period_id": target_period.pk, + "summary": { + "created": len(records), + "employees": len({record.employee_id for record in records}), + "skipped": skipped, + "replaced_cells": replaced_cells, + "voided": voided, + }, + "record_ids": [record.pk for record in records], + "idempotent_replay": False, + } diff --git a/backend/finance/payroll/periods.py b/backend/finance/payroll/periods.py new file mode 100644 index 00000000..c616064a --- /dev/null +++ b/backend/finance/payroll/periods.py @@ -0,0 +1,112 @@ +"""Calendar payroll period factory shared by scheduling and period closure.""" + +import logging +from calendar import monthrange +from datetime import date + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction + +from finance.models import PayrollAuditEvent, PayrollPeriod + +logger = logging.getLogger(__name__) + +MONTH_NAMES_RU = ( + "", + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", +) + + +def ensure_calendar_payroll_period( + reference_date: date, + *, + currency="RUB", + actor=None, + channel="automation", + metadata=None, +): + """Create the open payroll period for a month unless it already exists.""" + + date_from = reference_date.replace(day=1) + date_to = reference_date.replace( + day=monthrange(reference_date.year, reference_date.month)[1] + ) + + existing = PayrollPeriod.objects.filter( + date_from=date_from, + date_to=date_to, + currency=currency, + ).first() + if existing is not None: + return existing, False + + period = PayrollPeriod( + code=date_from.strftime("%Y-%m"), + name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}", + date_from=date_from, + date_to=date_to, + currency=currency, + created_by=actor, + ) + + try: + with transaction.atomic(): + period.full_clean() + period.save(force_insert=True) + PayrollAuditEvent.objects.create( + actor=actor, + action="payroll.period_created", + object_type=period._meta.label_lower, + object_id=str(period.pk), + period=period, + metadata={"channel": channel, **(metadata or {})}, + ) + except IntegrityError: + # Celery retries or two workers can race. The database constraint makes + # creation safe; return the winner when it covers the same month. + existing = PayrollPeriod.objects.filter( + date_from=date_from, + date_to=date_to, + currency=currency, + ).first() + if existing is not None: + return existing, False + raise + except ValidationError as error: + # A concurrent opening can win between the first lookup and full_clean. + existing = PayrollPeriod.objects.filter( + date_from=date_from, date_to=date_to, currency=currency + ).first() + if existing is not None: + return existing, False + logger.exception( + "Payroll period opening failed", + extra={ + "event": "finance.payroll_period.open_failed", + "payroll_period_id": period.pk, + "period_code": period.code, + "error_type": type(error).__name__, + }, + ) + raise + + logger.info( + "Payroll period opened", + extra={ + "event": "finance.payroll_period.opened", + "payroll_period_id": period.pk, + "period_code": period.code, + }, + ) + return period, True diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 59000359..f2336cd8 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -51,6 +51,10 @@ from .adapter import ( employee_snapshot, point_base_accruals_for_employees, ) +from .automatic_input_copy import ( + copy_inputs_after_period_close, + lock_automatic_input_copy_policy, +) from .balances import opening_balances from .components import ( build_payroll_component_snapshot, @@ -865,7 +869,10 @@ def publish_run(run_id: int, *, actor) -> PayrollRun: @transaction.atomic def close_period(period_id: int, *, actor) -> PayrollPeriod: _require_permission(actor, "finance.publish_payroll") + lock_automatic_input_copy_policy() period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + if period.status == PayrollPeriodStatus.CLOSED: + return period if period.status != PayrollPeriodStatus.PUBLISHED: _operation_error( "INVALID_PERIOD_STATE", @@ -875,6 +882,9 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod: period.status = PayrollPeriodStatus.CLOSED period.save(update_fields=["component_snapshot", "status", "updated_at"]) _audit(actor=actor, action="payroll.period_closed", instance=period, period=period) + period.automatic_input_copy = copy_inputs_after_period_close( + source_period=period, actor=actor + ) return period @@ -892,6 +902,7 @@ def complete_period_workflow( if not has_simple_admin_access(actor): raise PayrollPermissionDenied("finance.full_payroll_admin") + lock_automatic_input_copy_policy() period = PayrollPeriod.objects.select_for_update().get(pk=period_id) run = ( PayrollRun.objects.select_for_update().get(pk=period.current_run_id) diff --git a/backend/finance/tasks.py b/backend/finance/tasks.py index e2edd666..ef169592 100644 --- a/backend/finance/tasks.py +++ b/backend/finance/tasks.py @@ -2,105 +2,20 @@ from __future__ import annotations -import logging -from calendar import monthrange from datetime import date from celery import shared_task -from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction from django.utils import timezone -from .models import PayrollAuditEvent, PayrollPeriod - -logger = logging.getLogger(__name__) - -MONTH_NAMES_RU = ( - "", - "Январь", - "Февраль", - "Март", - "Апрель", - "Май", - "Июнь", - "Июль", - "Август", - "Сентябрь", - "Октябрь", - "Ноябрь", - "Декабрь", -) +from finance.payroll.periods import ensure_calendar_payroll_period def ensure_monthly_payroll_period(reference_date: date | None = None): - """Create the open payroll period for a month unless it already exists.""" - - reference_date = reference_date or timezone.localdate() - date_from = reference_date.replace(day=1) - date_to = reference_date.replace( - day=monthrange(reference_date.year, reference_date.month)[1] + return ensure_calendar_payroll_period( + reference_date or timezone.localdate(), + metadata={"schedule": "monthly"}, ) - existing = PayrollPeriod.objects.filter( - date_from=date_from, - date_to=date_to, - ).first() - if existing is not None: - return existing, False - - period = PayrollPeriod( - code=date_from.strftime("%Y-%m"), - name=f"{MONTH_NAMES_RU[date_from.month]} {date_from.year}", - date_from=date_from, - date_to=date_to, - currency="RUB", - created_by=None, - ) - - try: - with transaction.atomic(): - period.full_clean() - period.save(force_insert=True) - PayrollAuditEvent.objects.create( - actor=None, - action="payroll.period_created", - object_type=period._meta.label_lower, - object_id=str(period.pk), - period=period, - metadata={"channel": "automation", "schedule": "monthly"}, - ) - except IntegrityError: - # Celery retries or two workers can race. The database constraint makes - # creation safe; return the winner when it covers the same month. - existing = PayrollPeriod.objects.filter( - date_from=date_from, - date_to=date_to, - ).first() - if existing is not None: - return existing, False - raise - except ValidationError as error: - logger.exception( - "Payroll period opening failed", - extra={ - "event": "finance.payroll_period.open_failed", - "payroll_period_id": period.pk, - "period_code": period.code, - "error_type": type(error).__name__, - }, - ) - raise - - logger.info( - "Payroll period opened", - extra={ - "event": "finance.payroll_period.opened", - "payroll_period_id": period.pk, - "period_code": period.code, - }, - ) - return period, True - @shared_task def open_monthly_payroll_period(): diff --git a/backend/tests/finance/test_automatic_input_copy.py b/backend/tests/finance/test_automatic_input_copy.py new file mode 100644 index 00000000..3cc66c0e --- /dev/null +++ b/backend/tests/finance/test_automatic_input_copy.py @@ -0,0 +1,489 @@ +import calendar +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from decimal import Decimal +from importlib import import_module +from threading import Barrier + +import pytest +from django.apps import apps +from django.db import IntegrityError, close_old_connections, connection, transaction +from django.urls import reverse +from django.utils import timezone + +from finance.models import ( + PayrollAuditEvent, + PayrollInputCopyConfiguration, + PayrollInputLine, + PayrollPeriod, + PayrollRun, + PayrollStatement, +) +from finance.payroll.automatic_input_copy import ( + lock_automatic_input_copy_policy, + save_input_copy_configuration, +) +from finance.payroll.exceptions import PayrollOperationError +from finance.payroll.services import close_period +from finance.tasks import ensure_monthly_payroll_period +from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(user_factory, auth_client_factory): + owner, closer, employee = user_factory(), user_factory(), user_factory() + grant(closer, "publish_payroll", "view_all_payroll", "manage_payroll_inputs") + source = PayrollPeriod.objects.create( + code="2026-10", + name="Октябрь", + date_from=date(2026, 10, 1), + date_to=date(2026, 10, 31), + status="published", + created_by=owner, + ) + configuration = PayrollInputCopyConfiguration.objects.create( + owner=owner, + component_codes=["BONUS"], + employee_scope="selected", + employee_ids=[employee.pk], + existing_mode="skip", + auto_apply_on_period_closure=True, + ) + original = PayrollInputLine.objects.create( + period=source, + employee=employee, + component_code="BONUS", + amount="1234.56", + status="approved", + approved_by=closer, + approved_at=timezone.now(), + created_by=owner, + ) + return ( + owner, + closer, + employee, + source, + configuration, + original, + auth_client_factory(closer), + ) + + +def target_period(setup, *, month=11, status="open"): + return PayrollPeriod.objects.create( + code=f"2026-{month:02d}", + date_from=date(2026, month, 1), + date_to=date(2026, month, calendar.monthrange(2026, month)[1]), + status=status, + created_by=setup[0], + ) + + +def test_close_uses_shared_policy_creates_next_month_and_only_drafts(setup): + owner, closer, employee, source, _, original, client = setup + before = PayrollInputLine.objects.filter(pk=original.pk).values().get() + response = client.post( + reverse("api:v1:finance-payroll:admin-period-close", kwargs={"pk": source.pk}), + {}, + format="json", + ) + assert response.status_code == 200, response.content + source.refresh_from_db() + assert source.status == "closed" + transfer = response.json()["automatic_input_copy"] + following = PayrollPeriod.objects.get(code="2026-11") + assert following.date_from == date(2026, 11, 1) and following.date_to == date( + 2026, 11, 30 + ) + assert following.status == "open" and following.current_run_id is None + assert transfer["target_period_created"] is True + assert transfer["configuration_owner_id"] == owner.pk + assert transfer["summary"]["created"] == 1 + copied = PayrollInputLine.objects.get(period=following) + assert copied.employee == employee and copied.amount == Decimal("1234.56") + assert copied.status == "draft" and copied.approved_by is None + assert copied.created_by == closer + assert PayrollInputLine.objects.filter(pk=original.pk).values().get() == before + assert not PayrollStatement.objects.exists() + event = PayrollAuditEvent.objects.get( + action="payroll.period_input_copy_auto_applied" + ) + assert event.period == source and event.actor == closer + assert event.metadata["trigger"] == "period_closed" + frozen = source.component_snapshot + close_period(source.pk, actor=closer) + source.refresh_from_db() + assert source.component_snapshot == frozen + assert PayrollInputLine.objects.filter(period=following).count() == 1 + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.period_input_copy_auto_applied" + ).count() + == 1 + ) + + +@pytest.mark.parametrize( + "mode,count,voided", [("skip", 1, False), ("replace", 1, True)] +) +def test_existing_next_month_respects_saved_cell_policy(setup, mode, count, voided): + _, closer, employee, source, configuration, _, _ = setup + following = target_period(setup) + configuration.existing_mode = mode + configuration.save(update_fields=["existing_mode"]) + existing = PayrollInputLine.objects.create( + period=following, + employee=employee, + component_code="BONUS", + amount="50", + created_by=closer, + ) + closed = close_period(source.pk, actor=closer) + existing.refresh_from_db() + assert (existing.status == "voided") is voided + assert ( + PayrollInputLine.objects.filter(period=following, status="draft").count() + == count + ) + assert closed.automatic_input_copy["target_period_created"] is False + assert closed.automatic_input_copy["summary"]["created"] == int(mode == "replace") + + +def test_manual_and_background_opening_do_not_transfer_even_when_enabled(setup): + _, closer, _, source, _, _, client = setup + opened, _ = ensure_monthly_payroll_period(date(2026, 11, 1)) + assert not PayrollInputLine.objects.filter(period=opened).exists() + assert not PayrollAuditEvent.objects.filter( + action="payroll.period_input_copy_auto_applied" + ).exists() + response = client.post( + reverse("api:v1:finance-payroll:admin-period-list"), + { + "code": "2026-12", + "date_from": "2026-12-01", + "date_to": "2026-12-31", + "currency": "RUB", + }, + format="json", + ) + assert response.status_code == 201, response.content + assert response.json()["automatic_input_copy"] is None + assert not PayrollInputLine.objects.filter(period_id=response.json()["id"]).exists() + close_period(source.pk, actor=closer) + assert PayrollInputLine.objects.filter(period=opened).count() == 1 + + +def test_closing_without_policy_does_not_create_future_period(setup): + _, closer, _, source, configuration, _, _ = setup + configuration.auto_apply_on_period_closure = False + configuration.save(update_fields=["auto_apply_on_period_closure"]) + closed = close_period(source.pk, actor=closer) + assert closed.status == "closed" and closed.automatic_input_copy is None + assert not PayrollPeriod.objects.filter(code="2026-11").exists() + + +@pytest.mark.parametrize( + "status", ["calculated", "review", "approved", "published", "closed"] +) +def test_next_calculated_or_closed_period_is_not_modified_and_source_stays_open( + setup, status +): + _, closer, _, source, _, _, _ = setup + following = target_period(setup, status=status) + frozen = PayrollPeriod.objects.filter(pk=following.pk).values().get() + with pytest.raises(PayrollOperationError) as exc: + close_period(source.pk, actor=closer) + assert exc.value.code == "NEXT_PERIOD_INPUTS_LOCKED" + source.refresh_from_db() + assert source.status == "published" and not source.component_snapshot + assert PayrollPeriod.objects.filter(pk=following.pk).values().get() == frozen + assert not PayrollInputLine.objects.filter(period=following).exists() + assert not PayrollAuditEvent.objects.filter(action="payroll.period_closed").exists() + + +def test_copy_failure_rolls_back_source_close_next_period_and_all_audits( + setup, monkeypatch +): + _, closer, _, source, _, _, _ = setup + + def fail(**kwargs): + raise RuntimeError("Copy failure") + + monkeypatch.setattr("finance.payroll.automatic_input_copy.apply_input_copy", fail) + with pytest.raises(RuntimeError): + close_period(source.pk, actor=closer) + source.refresh_from_db() + assert source.status == "published" and not source.component_snapshot + assert not PayrollPeriod.objects.filter(code="2026-11").exists() + assert not PayrollAuditEvent.objects.filter(action="payroll.period_closed").exists() + + +def test_only_final_approved_source_values_are_automatically_copied(setup): + _, closer, employee, source, _, _, _ = setup + PayrollInputLine.objects.create( + period=source, + employee=employee, + component_code="BONUS", + amount="100", + created_by=closer, + ) + close_period(source.pk, actor=closer) + assert PayrollInputLine.objects.get(period__code="2026-11").amount == Decimal( + "1234.56" + ) + + +def test_shared_policy_is_visible_and_can_be_replaced_by_another_manager(setup): + owner, closer, _, source, old, _, client = setup + following = target_period(setup) + preview = client.get( + reverse( + "api:v1:finance-payroll:admin-period-copy-input-lines", + kwargs={"pk": following.pk}, + ), + {"source_period_id": source.pk}, + ) + assert preview.status_code == 200, preview.content + assert preview.json()["configuration"]["auto_apply_on_period_closure"] is True + assert preview.json()["configuration"]["component_codes"] == ["BONUS"] + with transaction.atomic(): + lock_automatic_input_copy_policy() + save_input_copy_configuration( + actor=closer, + data={ + "component_codes": ["ADVANCE"], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "skip", + "remember_configuration": True, + "auto_apply_on_period_closure": True, + }, + ) + old.refresh_from_db() + assert old.owner == owner and old.auto_apply_on_period_closure is False + assert ( + PayrollInputCopyConfiguration.objects.get( + auto_apply_on_period_closure=True + ).owner + == closer + ) + with pytest.raises(IntegrityError), transaction.atomic(): + PayrollInputCopyConfiguration.objects.filter(pk=old.pk).update( + auto_apply_on_period_closure=True + ) + + +def test_migration_retains_current_enabled_policy(setup): + _, _, _, _, configuration, _, _ = setup + import_module( + "finance.migrations.0042_input_copy_on_period_closure" + ).keep_latest_automatic_policy(apps, None) + configuration.refresh_from_db() + assert configuration.auto_apply_on_period_closure is True + assert configuration.component_codes == ["BONUS"] + + +def test_manual_copy_without_automatic_flag_keeps_owners_enabled_policy(setup): + owner, _, _, _, configuration, _, _ = setup + with transaction.atomic(): + lock_automatic_input_copy_policy() + save_input_copy_configuration( + actor=owner, + data={ + "component_codes": ["BONUS"], + "employee_scope": "active", + "employee_ids": [], + "existing_mode": "skip", + "remember_configuration": True, + # Serializer default when a client omits both automatic flags. + "auto_apply_on_period_closure": False, + }, + update_automatic_policy=False, + ) + configuration.refresh_from_db() + assert configuration.auto_apply_on_period_closure is True + + +def test_open_target_with_existing_run_is_protected(setup): + _, closer, _, source, _, _, _ = setup + following = target_period(setup) + run = PayrollRun.objects.create(period=following, revision=1, requested_by=closer) + PayrollPeriod.objects.filter(pk=following.pk).update(current_run=run) + with pytest.raises(PayrollOperationError) as exc: + close_period(source.pk, actor=closer) + assert exc.value.code == "NEXT_PERIOD_INPUTS_LOCKED" + source.refresh_from_db() + assert source.status == "published" + assert not PayrollInputLine.objects.filter(period=following).exists() + + +@pytest.mark.parametrize("blocked_target", [False, True]) +def test_complete_workflow_transfers_or_rolls_back_every_stage( + setup, auth_client_factory, blocked_target +): + owner, closer, employee, source, _, _, _ = setup + closer.is_staff = True + closer.save(update_fields=["is_staff"]) + PayrollPeriod.objects.filter(pk=source.pk).update(status="open") + make_approved_period_inputs( + period=source, employee=employee, creator=owner, approver=closer + ) + if blocked_target: + following = target_period(setup, status="published") + before_target = PayrollPeriod.objects.filter(pk=following.pk).values().get() + response = auth_client_factory(closer).post( + reverse( + "api:v1:finance-payroll:admin-period-complete-workflow", + kwargs={"pk": source.pk}, + ), + {"expected_lock_version": 0}, + format="json", + ) + source.refresh_from_db() + if blocked_target: + assert response.status_code == 409, response.content + assert response.json()["code"] == "NEXT_PERIOD_INPUTS_LOCKED" + assert source.status == "open" and source.current_run_id is None + assert source.lock_version == 0 and not source.component_snapshot + assert not PayrollRun.objects.exists() + assert not PayrollStatement.objects.exists() + assert not PayrollAuditEvent.objects.exists() + assert ( + PayrollPeriod.objects.filter(pk=following.pk).values().get() + == before_target + ) + else: + assert response.status_code == 200, response.content + assert source.status == "closed" + assert ( + response.json()["period"]["automatic_input_copy"]["summary"]["created"] == 1 + ) + assert PayrollRun.objects.filter(period=source, status="published").count() == 1 + assert PayrollStatement.objects.filter(run__period=source).count() == 1 + assert not PayrollRun.objects.filter(period__code="2026-11").exists() + assert PayrollInputLine.objects.get(period__code="2026-11").status == "draft" + + +@pytest.mark.parametrize("scope,expected", [("active", 0), ("selected", 1), ("all", 1)]) +def test_inactive_employees_follow_saved_scope(setup, scope, expected): + _, closer, employee, source, configuration, _, _ = setup + employee.is_active = False + employee.save(update_fields=["is_active"]) + configuration.employee_scope = scope + configuration.save(update_fields=["employee_scope"]) + close_period(source.pk, actor=closer) + assert PayrollInputLine.objects.filter(period__code="2026-11").count() == expected + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_close_copies_once_and_creates_one_next_period(setup): + if connection.vendor != "postgresql": + pytest.skip("Requires PostgreSQL row locks") + _, closer, _, source, _, _, _ = setup + barrier = Barrier(2) + + def close(): + close_old_connections() + try: + from django.contrib.auth import get_user_model + + actor = get_user_model().objects.get(pk=closer.pk) + barrier.wait(timeout=15) + return close_period(source.pk, actor=actor).status + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(close) for _ in range(2)] + assert [future.result(timeout=30) for future in futures] == ["closed", "closed"] + assert PayrollPeriod.objects.filter(code="2026-11").count() == 1 + assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1 + assert ( + PayrollAuditEvent.objects.filter( + action="payroll.period_input_copy_auto_applied" + ).count() + == 1 + ) + + +def test_close_creates_adjacent_month_not_an_unrelated_later_period(setup): + _, closer, _, source, _, _, _ = setup + december = target_period(setup, month=12) + close_period(source.pk, actor=closer) + assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1 + assert not PayrollInputLine.objects.filter(period=december).exists() + + +def test_year_boundary_creates_january(setup): + _, closer, _, source, _, _, _ = setup + PayrollPeriod.objects.filter(pk=source.pk).update( + code="2026-12", date_from=date(2026, 12, 1), date_to=date(2026, 12, 31) + ) + close_period(source.pk, actor=closer) + following = PayrollPeriod.objects.get(code="2027-01") + assert following.date_from == date(2027, 1, 1) and following.date_to == date( + 2027, 1, 31 + ) + + +def test_target_date_overlap_rejects_close_without_partial_creation(setup): + _, closer, _, source, _, _, _ = setup + PayrollPeriod.objects.create( + code="part-november", date_from=date(2026, 11, 15), date_to=date(2026, 11, 30) + ) + with pytest.raises(PayrollOperationError) as exc: + close_period(source.pk, actor=closer) + assert exc.value.code == "NEXT_PERIOD_CONFLICT" + source.refresh_from_db() + assert source.status == "published" + assert not PayrollPeriod.objects.filter(code="2026-11").exists() + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize("opening", ["scheduler", "manual"]) +def test_concurrent_opening_and_closing_uses_one_period_without_double_copy( + setup, opening +): + if connection.vendor != "postgresql": + pytest.skip("Requires PostgreSQL row locks") + _, closer, _, source, _, _, _ = setup + barrier = Barrier(2) + + def work(close): + close_old_connections() + try: + from django.contrib.auth import get_user_model + + actor = get_user_model().objects.get(pk=closer.pk) + barrier.wait(timeout=15) + if close: + return close_period(source.pk, actor=actor).status + if opening == "scheduler": + return ensure_monthly_payroll_period(date(2026, 11, 1))[0].code + from rest_framework.test import APIClient + + client = APIClient() + client.force_authenticate(actor) + return client.post( + reverse("api:v1:finance-payroll:admin-period-list"), + { + "code": "2026-11", + "date_from": "2026-11-01", + "date_to": "2026-11-30", + "currency": "RUB", + }, + format="json", + ).status_code + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as executor: + closing = executor.submit(work, True) + creating = executor.submit(work, False) + assert closing.result(timeout=30) == "closed" + assert creating.result(timeout=30) in ("2026-11", 201, 400, 409) + assert PayrollPeriod.objects.filter(code="2026-11").count() == 1 + assert PayrollInputLine.objects.filter(period__code="2026-11").count() == 1 diff --git a/backend/tests/finance/test_automatic_input_copy_migration.py b/backend/tests/finance/test_automatic_input_copy_migration.py new file mode 100644 index 00000000..b8b0f2e1 --- /dev/null +++ b/backend/tests/finance/test_automatic_input_copy_migration.py @@ -0,0 +1,84 @@ +"""Run with migrations enabled, against the disposable test database only.""" + +from datetime import date, datetime, timedelta + +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + +pytestmark = pytest.mark.django_db(transaction=True) + + +@pytest.mark.parametrize("enabled_count", [0, 1, 3]) +def test_closure_migration_preserves_latest_policy_and_historical_records( + request, user_factory, enabled_count +): + if request.config.getoption("nomigrations", default=False): + pytest.skip("Requires the real migration graph; run without --nomigrations") + owners = [user_factory() for _ in range(3)] + before_target = ("finance", "0041_salary_participation_and_balance_rules") + after_target = ("finance", "0042_input_copy_on_period_closure") + executor = MigrationExecutor(connection) + executor.migrate([before_target]) + try: + old_apps = executor.loader.project_state([before_target]).apps + Configuration = old_apps.get_model("finance", "PayrollInputCopyConfiguration") + Period = old_apps.get_model("finance", "PayrollPeriod") + Input = old_apps.get_model("finance", "PayrollInputLine") + source = Period.objects.create( + code="2026-08", + date_from=date(2026, 8, 1), + date_to=date(2026, 8, 31), + status="closed", + balance_rules_version=1, + component_snapshot={"version": 1, "components": []}, + ) + Input.objects.create( + period=source, + employee_id=owners[0].pk, + component_code="BONUS", + amount="123.45", + status="approved", + created_by_id=owners[1].pk, + approved_by_id=owners[2].pk, + approved_at=timezone.now(), + ) + before_periods = list(Period.objects.order_by("pk").values()) + before_inputs = list(Input.objects.order_by("pk").values()) + saved_at = timezone.make_aware(datetime(2026, 10, 18, 12)) + for index, owner in enumerate(owners): + config = Configuration.objects.create( + owner_id=owner.pk, + component_codes=["BONUS"], + auto_apply_on_period_creation=index < enabled_count, + ) + # Equal timestamps exercise the deterministic primary-key tie break. + Configuration.objects.filter(pk=config.pk).update( + updated_at=saved_at - timedelta(days=1) if index == 0 else saved_at + ) + expected_owner = owners[enabled_count - 1].pk if enabled_count else None + executor = MigrationExecutor(connection) + executor.migrate([after_target]) + new_apps = executor.loader.project_state([after_target]).apps + Configuration = new_apps.get_model("finance", "PayrollInputCopyConfiguration") + enabled = Configuration.objects.filter(auto_apply_on_period_closure=True) + assert list(enabled.values_list("owner_id", flat=True)) == ( + [expected_owner] if enabled_count else [] + ) + assert Configuration.objects.count() == 3 + assert all(c.component_codes == ["BONUS"] for c in Configuration.objects.all()) + # The old application release can still read its column during deployment. + OldConfiguration = old_apps.get_model("finance", "PayrollInputCopyConfiguration") + assert OldConfiguration.objects.count() == 3 + assert list( + OldConfiguration.objects.filter(auto_apply_on_period_creation=True) + .values_list("owner_id", flat=True) + ) == ([expected_owner] if enabled_count else []) + Period = new_apps.get_model("finance", "PayrollPeriod") + Input = new_apps.get_model("finance", "PayrollInputLine") + assert list(Period.objects.order_by("pk").values()) == before_periods + assert list(Input.objects.order_by("pk").values()) == before_inputs + finally: + # Keep the schema current for the following tests, even after a failure. + MigrationExecutor(connection).migrate([after_target]) diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 3d8caa69..f75371e6 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -867,8 +867,18 @@ def test_copy_input_lines_previews_scope_and_handles_existing_values( format="json", ) assert august.status_code == 201, august.content - assert august.json()["automatic_input_copy"]["source_period_id"] == target.pk - assert august.json()["automatic_input_copy"]["summary"]["created"] == 3 + assert august.json()["automatic_input_copy"] is None + assert not PayrollInputLine.objects.filter(period_id=august.json()["id"]).exists() + checker = user_factory() + PayrollInputLine.objects.filter(period=target, status="draft").update( + status="approved", approved_by=checker, approved_at=timezone.now() + ) + PayrollPeriod.objects.filter(pk=target.pk).update(status="published") + grant(manager, "publish_payroll") + closed = client.post(api_url("admin-period-close", pk=target.pk), {}, format="json") + assert closed.status_code == 200, closed.content + assert closed.json()["automatic_input_copy"]["source_period_id"] == target.pk + assert closed.json()["automatic_input_copy"]["summary"]["created"] == 3 august_lines = PayrollInputLine.objects.filter( period_id=august.json()["id"], status="draft", diff --git a/backend/tests/finance/test_payroll_scheduled_changes.py b/backend/tests/finance/test_payroll_scheduled_changes.py index ff1adc77..720e9c6d 100644 --- a/backend/tests/finance/test_payroll_scheduled_changes.py +++ b/backend/tests/finance/test_payroll_scheduled_changes.py @@ -851,7 +851,7 @@ def test_editing_and_clearing_marker_recalculates_open_target_once( assert row["applied_scheduled_changes"] == [] -def test_period_creation_copies_then_applies_pending_marker_once( +def test_period_closure_copies_then_applies_pending_marker_once( scheduled_workspace, ): workspace = scheduled_workspace @@ -885,7 +885,7 @@ def test_period_creation_copies_then_applies_pending_marker_once( employee_scope="selected", employee_ids=[employee.pk], existing_mode="replace", - auto_apply_on_period_creation=True, + auto_apply_on_period_closure=True, ) workspace["periods"][9].delete() @@ -903,7 +903,15 @@ def test_period_creation_copies_then_applies_pending_marker_once( ) assert created.status_code == 201, created.content - assert created.json()["automatic_input_copy"]["summary"]["created"] == 1 + assert created.json()["automatic_input_copy"] is None + PayrollInputLine.objects.filter(period=source, status="draft").update( + status="approved", approved_by=employee, approved_at=timezone.now() + ) + PayrollPeriod.objects.filter(pk=source.pk).update(status="published") + grant(manager, "publish_payroll") + closed = client.post(api_url("admin-period-close", pk=source.pk), {}, format="json") + assert closed.status_code == 200, closed.content + assert closed.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" diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 1de87c59..d4072390 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -709,7 +709,7 @@ export function PayrollAdminWorkspace({ ); const [accessModalOpen, setAccessModalOpen] = useState(false); const createdPeriodIdRef = useRef(null); - const createdPeriodInputCopyRef = useRef(null); + const closedPeriodInputCopyRef = useRef(null); const selectedPeriod = workspace?.selected_period || null; @@ -1138,7 +1138,8 @@ export function PayrollAdminWorkspace({ title: "Закрыть период", description: "Закрытый период больше нельзя пересчитывать.", confirmLabel: "Закрыть период", - warning: "Это финальное действие для расчётного периода.", + warning: + "Это финальное действие. Если автоперенос включён, будут созданы черновики в следующем месяце; рассчитанный следующий период изменяться не будет.", successMessage: "Период закрыт.", execute: () => apiClient.closePayrollAdminPeriod(selectedPeriod.id), }, @@ -1177,7 +1178,7 @@ export function PayrollAdminWorkspace({ : undefined, reasonRequired: requiresRecalculationReason, warning: - "Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.", + "Расчётные листки станут доступны сотрудникам, а период будет закрыт. Если автоперенос включён, он создаст черновики в следующем месяце. Если любой этап не пройдёт проверку, ни одно изменение не сохранится.", successMessage: "Расчёт опубликован, период закрыт.", execute: (reason) => apiClient.completePayrollAdminPeriodWorkflow(selectedPeriod.id, { @@ -1309,8 +1310,6 @@ export function PayrollAdminWorkspace({ else { const createdPeriod = await apiClient.createPayrollAdminPeriod(payload); createdPeriodIdRef.current = createdPeriod.id; - createdPeriodInputCopyRef.current = - createdPeriod.automatic_input_copy || null; } }; const saveRate = async (payload: PayrollPayRateWrite) => { @@ -2427,15 +2426,10 @@ export function PayrollAdminWorkspace({ onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; - const automaticInputCopy = createdPeriodInputCopyRef.current; createdPeriodIdRef.current = null; - createdPeriodInputCopyRef.current = null; if (createdPeriodId) { - const copiedCount = automaticInputCopy?.summary.created || 0; setNotice( - copiedCount > 0 - ? `Период создан и выбран. Автоматически перенесено записей: ${copiedCount}.` - : "Период создан и выбран.", + "Период создан и выбран. Автоперенос выполняется при закрытии исходного месяца.", ); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); @@ -2708,9 +2702,26 @@ export function PayrollAdminWorkspace({ reasonRequired={pendingAction.reasonRequired} warning={pendingAction.warning} onClose={() => setPendingAction(null)} - onConfirm={pendingAction.execute} + onConfirm={async (reason) => { + closedPeriodInputCopyRef.current = null; + const result = await pendingAction.execute(reason); + const payload = result as { + period?: PayrollAdminPeriod; + automatic_input_copy?: PayrollInputCopyResult | null; + } | null; + closedPeriodInputCopyRef.current = + payload?.period?.automatic_input_copy || + payload?.automatic_input_copy || + null; + }} onDone={async () => { - const message = pendingAction.successMessage; + const copy = closedPeriodInputCopyRef.current; + const message = + pendingAction.successMessage + + (copy + ? ` Автоперенос в ${copy.target_period_code || "следующий месяц"}: создано черновиков — ${copy.summary.created}, пропущено заполненных — ${copy.summary.skipped}.` + : ""); + closedPeriodInputCopyRef.current = null; setPendingAction(null); await refreshView(message); }} diff --git a/frontend/src/components/finance/admin/PayrollInputCopyModal.tsx b/frontend/src/components/finance/admin/PayrollInputCopyModal.tsx index de895600..c2e7c9d7 100644 --- a/frontend/src/components/finance/admin/PayrollInputCopyModal.tsx +++ b/frontend/src/components/finance/admin/PayrollInputCopyModal.tsx @@ -65,7 +65,7 @@ export function PayrollInputCopyModal({ const [existingMode, setExistingMode] = useState("skip"); const [rememberConfiguration, setRememberConfiguration] = useState(false); - const [autoApplyOnCreation, setAutoApplyOnCreation] = useState(false); + const [autoApplyOnClosure, setAutoApplyOnClosure] = useState(false); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(Boolean(sourcePeriods.length)); const [busy, setBusy] = useState(false); @@ -105,8 +105,8 @@ export function PayrollInputCopyModal({ ); setExistingMode(payload.configuration.existing_mode); setRememberConfiguration(payload.configuration.saved); - setAutoApplyOnCreation( - payload.configuration.auto_apply_on_period_creation, + setAutoApplyOnClosure( + payload.configuration.auto_apply_on_period_closure, ); }) .catch((loadError: unknown) => { @@ -219,8 +219,8 @@ export function PayrollInputCopyModal({ expected_period_lock_version: period.lock_version, idempotency_key: idempotencyKey, remember_configuration: rememberConfiguration, - auto_apply_on_period_creation: - rememberConfiguration && autoApplyOnCreation, + auto_apply_on_period_closure: + rememberConfiguration && autoApplyOnClosure, }); await onSaved(result); } catch (submitError: unknown) { @@ -567,7 +567,7 @@ export function PayrollInputCopyModal({ checked={rememberConfiguration} onChange={(event) => { setRememberConfiguration(event.target.checked); - if (!event.target.checked) setAutoApplyOnCreation(false); + if (!event.target.checked) setAutoApplyOnClosure(false); }} /> @@ -586,20 +586,21 @@ export function PayrollInputCopyModal({ > - setAutoApplyOnCreation(event.target.checked) + setAutoApplyOnClosure(event.target.checked) } /> - Автоматически применять при создании периода + Автоматически переносить при закрытии периода - При создании следующего периода брать ближайший предыдущий - период той же валюты и создавать черновики по этой - конфигурации. + Общая настройка финансов: при закрытии месяца любым + уполномоченным сотрудником выбранные записи переносятся + черновиками в следующий месяц. Если его ещё нет, он будет + создан. Открытие периода автоперенос не запускает. diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 53c980d4..daa2a6be 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -907,7 +907,7 @@ export type PayrollInputCopyPreview = { employee_scope: PayrollInputCopyEmployeeScope; employee_ids: number[]; existing_mode: PayrollInputCopyExistingMode; - auto_apply_on_period_creation: boolean; + auto_apply_on_period_closure: boolean; }; }; @@ -920,10 +920,13 @@ export type PayrollInputCopyWrite = { expected_period_lock_version: number; idempotency_key: string; remember_configuration: boolean; - auto_apply_on_period_creation: boolean; + auto_apply_on_period_closure: boolean; }; export type PayrollInputCopyResult = { + target_period_code?: string; + target_period_created?: boolean; + configuration_owner_id?: number; source_period_id: number; target_period_id: number; summary: { diff --git a/frontend/src/lib/payroll-period-closure.test.js b/frontend/src/lib/payroll-period-closure.test.js new file mode 100644 index 00000000..df5a27e7 --- /dev/null +++ b/frontend/src/lib/payroll-period-closure.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { createFinanceApi } from "./api/finance.ts"; + +test("saved automatic copy policy uses the closure trigger", async () => { + const calls = []; + const api = createFinanceApi(async (endpoint, options) => { + calls.push({ endpoint, options }); + return {}; + }); + const payload = { + source_period_id: 6, + component_codes: ["BONUS"], + employee_scope: "active", + employee_ids: [], + existing_mode: "skip", + remember_configuration: true, + auto_apply_on_period_closure: true, + expected_period_lock_version: 0, + idempotency_key: "copy-key", + }; + await api.copyPayrollAdminInputLines(7, payload); + await api.closePayrollAdminPeriod(7); + assert.equal(calls[0].endpoint, "/api/v1/finance/payroll/admin/periods/7/copy-input-lines/"); + assert.deepEqual(JSON.parse(calls[0].options.body), payload); + assert(!("auto_apply_on_period_creation" in JSON.parse(calls[0].options.body))); + assert.equal(calls[1].endpoint, "/api/v1/finance/payroll/admin/periods/7/close/"); +}); + +test("copy settings describe shared closure policy, not automatic opening", () => { + const source = readFileSync(new URL("../components/finance/admin/PayrollInputCopyModal.tsx", import.meta.url), "utf8"); + assert(source.includes("Автоматически переносить при закрытии периода")); + assert(source.includes("Общая настройка финансов")); + assert(!source.includes("auto_apply_on_period_creation")); + assert(!source.includes("Автоматически применять при создании периода")); +});