diff --git a/backend/api/v1/finance/admin/period_views/carryovers.py b/backend/api/v1/finance/admin/period_views/carryovers.py index 0419b7bd..71a0cf0f 100644 --- a/backend/api/v1/finance/admin/period_views/carryovers.py +++ b/backend/api/v1/finance/admin/period_views/carryovers.py @@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response +from finance.enums import PayrollPeriodStatus from finance.models import ( PayrollCarryover, PayrollInputLine, @@ -30,6 +31,7 @@ from ..common import ( PAYROLL_PERMISSIONS, PayrollAdminAPIView, _audit_api_mutation, + _conflict, ) from ..queries import ( _carryover_ledger, @@ -84,18 +86,24 @@ class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView): def patch(self, request, pk): command = PayrollCarryoverInclusionSerializer(data=request.data) command.is_valid(raise_exception=True) - carryover_component_codes = { - component.code - for component in get_payroll_components() - if component.include_in_carryover - } line = get_object_or_404( PayrollInputLine.objects.select_for_update(of=("self",)).select_related( "period" ), pk=pk, - component_code__in=carryover_component_codes, ) + if line.period.status == PayrollPeriodStatus.CLOSED: + _conflict( + "PERIOD_INPUTS_LOCKED", + "Данные закрытого периода заблокированы.", + ) + carryover_component_codes = { + component.code + for component in get_payroll_components(line.period) + if component.include_in_carryover + } + if line.component_code not in carryover_component_codes: + raise Http404 included = command.validated_data["included"] previous = line.include_in_carryover if previous != included: diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 6e701808..b61c37c1 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -18,7 +18,7 @@ from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from finance.enums import ApprovalStatus, PayrollComponentKind +from finance.enums import ApprovalStatus, PayrollComponentKind, PayrollPeriodStatus from finance.models import ( FinanceSettings, PayrollCustomComponent, @@ -55,6 +55,15 @@ from ..common import ( from ..input_copy import _apply_input_copy +def _reject_closed_component_period(period): + if period is not None and period.status == PayrollPeriodStatus.CLOSED: + _conflict( + "PERIOD_COMPONENTS_LOCKED", + "Колонки закрытого периода сохранены как неизменяемый снимок.", + period_id=period.pk, + ) + + class PayrollAdminEmployeeListView(PayrollAdminAPIView): serializer_class = PayrollEmployeeMiniSerializer @@ -92,6 +101,10 @@ class PayrollAdminComponentListView(PayrollAdminAPIView): self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) serializer = PayrollCustomComponentWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) + operation_period = PayrollPeriod.objects.filter( + pk=request.data.get("period_id") + ).first() + _reject_closed_component_period(operation_period) next_order = ( PayrollCustomComponent.objects.order_by("-display_order") .values_list("display_order", flat=True) @@ -137,6 +150,10 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc serializer = PayrollCustomComponentWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) + operation_period = PayrollPeriod.objects.filter( + pk=request.data.get("period_id") + ).first() + _reject_closed_component_period(operation_period) values = dict(serializer.validated_data) scope_period = values.pop("scope_period", None) if ( @@ -323,6 +340,7 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): code=code, ) period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id")) + _reject_closed_component_period(period) mode = request.data.get("mode") scheduled_references = scheduled_components_targeting(code) if scheduled_references: diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index 51c198c3..388a5293 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -113,17 +113,11 @@ def _automatic_balance_summaries(rows): def _carryover_ledger(period): - carryover_components = { - component.code: component - for component in get_payroll_components() - if component.include_in_carryover - } - lines = ( + lines = list( PayrollInputLine.objects.filter( period__currency=period.currency, period__date_to__lte=period.date_to, employee__is_hidden_in_ui=False, - component_code__in=carryover_components, ) .exclude( source=InputSource.API, @@ -138,11 +132,24 @@ def _carryover_ledger(period): .prefetch_related("employee__departments_links__department") .order_by("-period__date_to", "-created_at", "-id") ) + components_by_period = { + source_period.pk: { + component.code: component + for component in get_payroll_components(source_period) + if component.include_in_carryover + } + for source_period in {line.period for line in lines} + } entries = [] balances_by_employee = {} included_count = 0 excluded_count = 0 for line in lines: + component = components_by_period.get(line.period_id, {}).get( + line.component_code + ) + if component is None: + continue current_period_line = line.period_id == period.pk active_status = line.status == ApprovalStatus.APPROVED or ( current_period_line @@ -150,7 +157,6 @@ def _carryover_ledger(period): and line.status == ApprovalStatus.DRAFT ) counts_in_balance = bool(line.include_in_carryover and active_status) - component = carryover_components[line.component_code] raw_contribution = ( line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount ) diff --git a/backend/finance/migrations/0038_payrollperiod_component_snapshot.py b/backend/finance/migrations/0038_payrollperiod_component_snapshot.py new file mode 100644 index 00000000..012b8598 --- /dev/null +++ b/backend/finance/migrations/0038_payrollperiod_component_snapshot.py @@ -0,0 +1,187 @@ +from django.db import migrations, models + +SNAPSHOT_FIELDS = ( + "code", + "name", + "kind", + "requires_reason", + "display_order", + "include_in_point_base", + "include_in_carryover", + "visible_to_employee", + "period_scope", + "scope_period_id", + "is_custom", + "highlight_color", + "scheduled_target_component_code", +) + +BUILTIN_COMPONENTS = ( + ("VACATION_PAY", "Отпускные", "earning", False, 20, False, False, True, ""), + ("BONUS", "Премия", "earning", False, 30, True, False, True, ""), + ( + "CORRECTION_CREDIT", + "Положительная корректировка", + "adjustment_credit", + True, + 40, + False, + True, + True, + "green", + ), + ( + "CORRECTION_DEBIT", + "Отрицательная корректировка", + "adjustment_debit", + True, + 41, + False, + True, + True, + "red", + ), + ( + "ONE_TIME_PAYMENT", + "Разовое начисление", + "earning", + True, + 50, + False, + False, + True, + "", + ), + ("DEDUCTION", "Удержание", "deduction", True, 60, False, False, True, ""), + ("ADVANCE", "Аванс", "payment", False, 70, False, False, True, ""), + ( + "SALARY_PAYMENT", + "Фактическая выплата", + "payment", + True, + 80, + False, + False, + True, + "", + ), +) + + +def _applies_to_period(component, period, period_dates, excluded_period_ids=()): + hidden_from_period_id = component.get("hidden_from_period_id") + if ( + hidden_from_period_id is not None + and period_dates.get(hidden_from_period_id) is not None + and period.date_from >= period_dates[hidden_from_period_id] + ): + return False + if period.pk in excluded_period_ids: + return False + scope = component["period_scope"] + if scope == "all": + return True + scope_period_id = component.get("scope_period_id") + if scope_period_id is None: + return False + if scope == "current_only": + return scope_period_id == period.pk + return period_dates.get(scope_period_id, period.date_from) <= period.date_from + + +def backfill_closed_period_snapshots(apps, schema_editor): + # Older configurations were not versioned, so the configuration visible at + # this deployment is the only baseline that can be retained for old periods. + finance_settings = apps.get_model("finance", "FinanceSettings") + payroll_custom_component = apps.get_model("finance", "PayrollCustomComponent") + payroll_period = apps.get_model("finance", "PayrollPeriod") + settings = finance_settings.objects.first() + overrides = settings.payroll_component_overrides if settings else {} + periods = list(payroll_period.objects.all()) + period_dates = {period.pk: period.date_from for period in periods} + + builtins = [] + for ( + code, + name, + kind, + requires_reason, + display_order, + include_in_point_base, + include_in_carryover, + visible_to_employee, + highlight_color, + ) in BUILTIN_COMPONENTS: + component = { + "code": code, + "name": name, + "kind": kind, + "requires_reason": requires_reason, + "display_order": display_order, + "include_in_point_base": include_in_point_base, + "include_in_carryover": include_in_carryover, + "visible_to_employee": visible_to_employee, + "period_scope": "all", + "scope_period_id": None, + "is_custom": False, + "highlight_color": highlight_color, + "scheduled_target_component_code": "", + } + component.update(overrides.get(code, {})) + builtins.append({field: component[field] for field in SNAPSHOT_FIELDS}) + + custom_components = [] + for component in payroll_custom_component.objects.filter(is_active=True): + payload = { + field: getattr(component, field) + for field in SNAPSHOT_FIELDS + if field != "is_custom" + } + payload["is_custom"] = True + payload["hidden_from_period_id"] = component.hidden_from_period_id + payload["excluded_period_ids"] = set( + component.excluded_periods.values_list("pk", flat=True) + ) + custom_components.append(payload) + + for period in periods: + if period.status != "closed" or period.component_snapshot: + continue + components = [ + component + for component in builtins + if _applies_to_period(component, period, period_dates) + ] + components.extend( + {field: component[field] for field in SNAPSHOT_FIELDS} + for component in custom_components + if _applies_to_period( + component, + period, + period_dates, + component["excluded_period_ids"], + ) + ) + period.component_snapshot = {"version": 1, "components": components} + period.save(update_fields=["component_snapshot"]) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0037_defer_scheduled_changes")] + + operations = [ + migrations.AddField( + model_name="payrollperiod", + name="component_snapshot", + field=models.JSONField( + blank=True, + default=dict, + editable=False, + verbose_name="Снимок зарплатных колонок", + ), + ), + migrations.RunPython( + backfill_closed_period_snapshots, + migrations.RunPython.noop, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 3856be40..9ec72970 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -124,6 +124,12 @@ class PayrollPeriod(models.Model): related_name="active_for_periods", verbose_name="Текущая ревизия расчёта", ) + component_snapshot = models.JSONField( + "Снимок зарплатных колонок", + default=dict, + blank=True, + editable=False, + ) lock_version = models.PositiveIntegerField("Версия блокировки", default=0) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -223,6 +229,14 @@ class PayrollPeriod(models.Model): if self.pk and not self._state.adding: original = PayrollPeriod.objects.select_for_update().get(pk=self.pk) self._validate_immutable_fields(original) + if ( + original.status == PayrollPeriodStatus.CLOSED + and original.component_snapshot + and self.component_snapshot != original.component_snapshot + ): + raise ValidationError( + "Снимок колонок закрытого периода нельзя изменять." + ) if update_fields is None: # Full-form saves may carry stale workflow fields. Those fields # are changed only by services with explicit update_fields. @@ -231,6 +245,7 @@ class PayrollPeriod(models.Model): self.lock_version = original.lock_version self.created_by_id = original.created_by_id self.created_at = original.created_at + self.component_snapshot = original.component_snapshot return super().save(*args, **kwargs) diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py index 0f2fd66c..d8f2ac65 100644 --- a/backend/finance/payroll/balances.py +++ b/backend/finance/payroll/balances.py @@ -21,31 +21,41 @@ def opening_balances( requested_ids = set(employee_ids) if not requested_ids: return {} - components = { - component.code: component - for component in get_payroll_components() - if component.include_in_carryover - } - if not components: - return {} - lines = ( + lines = list( PayrollInputLine.objects.filter( employee_id__in=requested_ids, period__currency=period.currency, period__date_to__lt=period.date_from, status=ApprovalStatus.APPROVED, include_in_carryover=True, - component_code__in=components, ) .exclude( source=InputSource.API, source_ref__startswith="payroll-carryover:", ) - .values("employee_id", "component_code", "amount") + .values("period_id", "employee_id", "component_code", "amount") ) + source_periods = { + source.pk: source + for source in PayrollPeriod.objects.filter( + pk__in={line["period_id"] for line in lines} + ) + } + components_by_period = { + period_id: { + component.code: component + for component in get_payroll_components(source_period) + if component.include_in_carryover + } + for period_id, source_period in source_periods.items() + } balances = {} for line in lines: - component = components[line["component_code"]] + component = components_by_period.get(line["period_id"], {}).get( + line["component_code"] + ) + if component is None: + continue direction = 1 if component.kind in CARRYOVER_DEBIT_KINDS else -1 balances[line["employee_id"]] = balances.get( line["employee_id"], Decimal("0.00") diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 617b2bc4..2ee89f79 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -2,7 +2,28 @@ from dataclasses import dataclass, replace -from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope +from finance.enums import ( + PayrollComponentKind, + PayrollComponentPeriodScope, + PayrollPeriodStatus, +) + +PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1 +PAYROLL_COMPONENT_SNAPSHOT_FIELDS = ( + "code", + "name", + "kind", + "requires_reason", + "display_order", + "include_in_point_base", + "include_in_carryover", + "visible_to_employee", + "period_scope", + "scope_period_id", + "is_custom", + "highlight_color", + "scheduled_target_component_code", +) @dataclass(frozen=True, slots=True) @@ -156,7 +177,7 @@ class PayrollComponent: objects = _Manager() -def get_payroll_components(period=None) -> _PayrollComponentCollection: +def _get_live_payroll_components(period=None) -> _PayrollComponentCollection: """Return built-ins plus active user-created component columns.""" from finance.models import FinanceSettings, PayrollCustomComponent, PayrollPeriod @@ -247,6 +268,53 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: return _PayrollComponentCollection((*builtins, *custom)) +def build_payroll_component_snapshot(period) -> dict[str, object]: + """Serialize the exact component configuration visible when a period closes.""" + + components = _get_live_payroll_components(period) + return { + "version": PAYROLL_COMPONENT_SNAPSHOT_VERSION, + "components": [ + { + field: getattr(component, field) + for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS + } + for component in components + ], + } + + +def _components_from_snapshot(snapshot) -> _PayrollComponentCollection: + if not isinstance(snapshot, dict): + raise ValueError("Некорректный снимок зарплатных колонок.") + if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION: + raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.") + payloads = snapshot.get("components") + if not isinstance(payloads, list): + raise ValueError("В снимке отсутствует список зарплатных колонок.") + try: + return _PayrollComponentCollection( + PayrollComponentDefinition( + **{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS} + ) + for payload in payloads + ) + except (KeyError, TypeError) as exc: + raise ValueError("Снимок зарплатных колонок повреждён.") from exc + + +def get_payroll_components(period=None) -> _PayrollComponentCollection: + """Return frozen columns for closed periods and live columns otherwise.""" + + if ( + period is not None + and period.status == PayrollPeriodStatus.CLOSED + and period.component_snapshot + ): + return _components_from_snapshot(period.component_snapshot) + return _get_live_payroll_components(period) + + def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition: component = get_payroll_components(period).filter(code=code) if component: diff --git a/backend/finance/payroll/scheduled_changes.py b/backend/finance/payroll/scheduled_changes.py index 9211abc8..121a0101 100644 --- a/backend/finance/payroll/scheduled_changes.py +++ b/backend/finance/payroll/scheduled_changes.py @@ -215,18 +215,23 @@ def applied_change_payloads(lines: list[PayrollInputLine]) -> list[dict[str, obj "period" ) } - source_codes = {line.component_code for line in sources.values()} - components = { - component.code: component - for component in get_payroll_components() - if component.code in source_codes + components_by_period = { + period_id: { + component.code: component + for component in get_payroll_components(source.period) + } + for period_id, source in { + source.period_id: source for source in sources.values() + }.items() } payloads = [] for generated_line in generated: source = sources.get(generated_source_line_id(generated_line)) if source is None: continue - component = components.get(source.component_code) + component = components_by_period.get(source.period_id, {}).get( + source.component_code + ) if component is None: continue payloads.append( diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 9873bd27..e4d2c210 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -52,7 +52,11 @@ from .adapter import ( point_base_accruals_for_employees, ) from .balances import opening_balances -from .components import get_payroll_component, get_payroll_components +from .components import ( + build_payroll_component_snapshot, + get_payroll_component, + get_payroll_components, +) from .config import ( base_rate_code, build_rules, @@ -826,8 +830,9 @@ def close_period(period_id: int, *, actor) -> PayrollPeriod: "INVALID_PERIOD_STATE", "Закрыть можно только опубликованный период.", ) + period.component_snapshot = build_payroll_component_snapshot(period) period.status = PayrollPeriodStatus.CLOSED - period.save(update_fields=["status", "updated_at"]) + period.save(update_fields=["component_snapshot", "status", "updated_at"]) _audit(actor=actor, action="payroll.period_closed", instance=period, period=period) return period diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 8fe0b251..080c35b3 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -220,6 +220,91 @@ def test_manager_can_customize_system_component_behavior( assert override["highlight_color"] == "violet" +def test_closed_period_table_keeps_frozen_columns_after_later_customization( + user_factory, + auth_client_factory, +): + manager = user_factory(email="component.snapshot.manager@example.test") + grant( + manager, + "manage_payroll_inputs", + "view_all_payroll", + "publish_payroll", + ) + june = make_period(creator=manager) + june.status = "published" + june.save(update_fields=["status", "updated_at"]) + july = PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ) + client = auth_client_factory(manager) + closed = client.post(api_url("admin-period-close", pk=june.pk), {}, format="json") + assert closed.status_code == 200, closed.content + + updated = client.patch( + api_url("admin-component-detail", code="BONUS"), + { + "name": "Новая премия", + "kind": "deduction", + "requires_reason": True, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": False, + "highlight_color": "red", + "period_scope": "all", + "period_id": july.pk, + }, + format="json", + ) + assert updated.status_code == 200, updated.content + + historical_table = client.get(api_url("admin-period-table", pk=june.pk)) + current_table = client.get(api_url("admin-period-table", pk=july.pk)) + assert historical_table.status_code == 200, historical_table.content + assert current_table.status_code == 200, current_table.content + historical_bonus = next( + column + for column in historical_table.json()["component_columns"] + if column["code"] == "BONUS" + ) + current_bonus = next( + column + for column in current_table.json()["component_columns"] + if column["code"] == "BONUS" + ) + assert historical_bonus["label"] == "Премия" + assert historical_bonus["kind"] == "earning" + assert historical_bonus["include_in_point_base"] is True + assert historical_bonus["visible_to_employee"] is True + assert historical_bonus["highlight_color"] == "" + assert current_bonus["label"] == "Новая премия" + assert current_bonus["kind"] == "deduction" + + rejected = client.patch( + api_url("admin-component-detail", code="BONUS"), + { + "name": "Попытка изменить историю", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": True, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "all", + "period_id": june.pk, + }, + format="json", + ) + assert rejected.status_code == 409, rejected.content + assert rejected.json()["code"] == "PERIOD_COMPONENTS_LOCKED" + + def test_component_settings_reject_negative_point_base_and_roll_back( user_factory, auth_client_factory, diff --git a/backend/tests/finance/test_payroll_carryovers.py b/backend/tests/finance/test_payroll_carryovers.py index 65920b58..a501a4d2 100644 --- a/backend/tests/finance/test_payroll_carryovers.py +++ b/backend/tests/finance/test_payroll_carryovers.py @@ -26,6 +26,7 @@ from finance.payroll.carryovers import ( materialize_pending_carryovers, ) from finance.payroll.exceptions import PayrollOperationError +from finance.payroll.services import close_period pytestmark = pytest.mark.django_db @@ -352,3 +353,68 @@ def test_component_setting_adds_existing_values_to_carryover_balance( ) assert excluded.status_code == 200, excluded.content assert opening_balances(july, [employee.pk]) == {} + + +def test_closed_period_carryover_uses_frozen_component_settings( + user_factory, + auth_client_factory, +): + manager = user_factory(email="carryover.snapshot.manager@example.test") + employee = user_factory(email="carryover.snapshot.employee@example.test") + grant( + manager, + "manage_payroll_inputs", + "view_all_payroll", + "publish_payroll", + ) + june = make_period(creator=manager, month=6) + july = make_period(creator=manager, month=7) + settings = FinanceSettings.get_solo() + settings.payroll_component_overrides = { + "ONE_TIME_PAYMENT": { + "include_in_carryover": True, + "kind": "earning", + } + } + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + line = PayrollInputLine.objects.create( + period=june, + employee=employee, + component_code="ONE_TIME_PAYMENT", + amount=Decimal("2400.00"), + reason="Разовая выплата", + status=ApprovalStatus.APPROVED, + created_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + ) + june.status = PayrollPeriodStatus.PUBLISHED + june.save(update_fields=["status", "updated_at"]) + close_period(june.pk, actor=manager) + + settings.payroll_component_overrides = { + "ONE_TIME_PAYMENT": { + "include_in_carryover": False, + "kind": "payment", + } + } + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + + assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")} + ledger = auth_client_factory(manager).get( + api_url("admin-period-carryovers", pk=july.pk) + ) + assert ledger.status_code == 200, ledger.content + assert ledger.json()["entries"][0]["component"]["kind"] == "earning" + assert ledger.json()["entries"][0]["raw_contribution"] == "-2400.00" + + blocked = auth_client_factory(manager).patch( + api_url("admin-input-line-carryover-inclusion", pk=line.pk), + {"included": False}, + format="json", + ) + assert blocked.status_code == 409, blocked.content + assert blocked.json()["code"] == "PERIOD_INPUTS_LOCKED" + line.refresh_from_db() + assert line.include_in_carryover is True diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index 689b3725..c21e959f 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -25,6 +25,7 @@ from finance.models import ( FinanceSettings, PayrollAuditEvent, PayrollComponent, + PayrollCustomComponent, PayrollInputLine, PayrollPeriod, PayrollRun, @@ -32,6 +33,7 @@ from finance.models import ( PayrollStatementAcknowledgement, PayrollWorkRecord, ) +from finance.payroll.components import get_payroll_components from finance.payroll.config import DEFAULT_CONFIG from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( @@ -589,6 +591,97 @@ def test_closed_period_cannot_be_recalculated_or_change_its_statement( assert PayrollRun.objects.filter(period=june_period).count() == original_run_count +def test_closing_period_freezes_component_configuration( + june_period, + payroll_users, +): + settings = FinanceSettings.get_solo() + settings.payroll_component_overrides = { + "BONUS": { + "name": "Премия до закрытия", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": True, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "green", + "period_scope": "all", + "scope_period_id": None, + "scheduled_target_component_code": "", + } + } + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + custom = PayrollCustomComponent.objects.create( + name="Историческая колонка", + kind="earning", + requires_reason=False, + include_in_point_base=False, + include_in_carryover=False, + visible_to_employee=True, + highlight_color="blue", + period_scope="all", + created_by=payroll_users["operator"], + ) + june_period.status = PayrollPeriodStatus.PUBLISHED + june_period.save(update_fields=["status", "updated_at"]) + + close_period(june_period.pk, actor=payroll_users["publisher"]) + june_period.refresh_from_db() + + closed_components = { + component.code: component for component in get_payroll_components(june_period) + } + assert june_period.component_snapshot["version"] == 1 + assert closed_components["BONUS"].name == "Премия до закрытия" + assert closed_components["BONUS"].highlight_color == "green" + assert closed_components[custom.code].name == "Историческая колонка" + + settings.payroll_component_overrides["BONUS"] = { + **settings.payroll_component_overrides["BONUS"], + "name": "Премия после закрытия", + "kind": "deduction", + "include_in_point_base": False, + "visible_to_employee": False, + "highlight_color": "red", + } + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + custom.delete() + + live_components = { + component.code: component for component in get_payroll_components() + } + historical_components = { + component.code: component for component in get_payroll_components(june_period) + } + assert live_components["BONUS"].name == "Премия после закрытия" + assert custom.code not in live_components + assert historical_components["BONUS"].name == "Премия до закрытия" + assert historical_components["BONUS"].kind == "earning" + assert historical_components["BONUS"].include_in_point_base is True + assert historical_components["BONUS"].visible_to_employee is True + assert historical_components["BONUS"].highlight_color == "green" + assert historical_components[custom.code].name == "Историческая колонка" + assert historical_components[custom.code].highlight_color == "blue" + + +def test_closed_period_component_snapshot_cannot_be_changed( + june_period, + payroll_users, +): + june_period.status = PayrollPeriodStatus.PUBLISHED + june_period.save(update_fields=["status", "updated_at"]) + close_period(june_period.pk, actor=payroll_users["publisher"]) + june_period.refresh_from_db() + original_snapshot = june_period.component_snapshot + june_period.component_snapshot = {"version": 1, "components": []} + + with pytest.raises(ValidationError): + june_period.save(update_fields=["component_snapshot", "updated_at"]) + + june_period.refresh_from_db() + assert june_period.component_snapshot == original_snapshot + + def test_override_permission_never_replaces_operational_approval_permissions( user_factory, ):