From e308d5916b730bdb496e2c82f0c0797c49cba2f3 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 11 Sep 2026 14:21:14 +0300 Subject: [PATCH] fix(finance): allow system columns as scheduled changes --- .../v1/finance/admin/period_views/catalog.py | 59 ++++++++++++------- backend/finance/payroll/components.py | 24 ++++++-- backend/finance/payroll/scheduled_changes.py | 41 +++++++------ .../finance/test_payroll_scheduled_changes.py | 57 +++++++++++++++--- .../finance/admin/PayrollPeriodTable.tsx | 16 ++--- 5 files changed, 135 insertions(+), 62 deletions(-) diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 6d7d8904..4e6ee7ff 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -30,6 +30,7 @@ from finance.payroll.adapter import point_base_accruals_for_employees from finance.payroll.components import ( get_payroll_components, ) +from finance.payroll.scheduled_changes import scheduled_components_targeting from ...admin_serializers import ( PayrollComponentSerializer, @@ -140,22 +141,23 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): None, ) if ( - not current.is_custom - and values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE + values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE + and values.get("scheduled_target_component_code") == code ): raise ValidationError( { - "kind": ( - "Запланированное изменение создаётся отдельной " - "пользовательской колонкой." + "scheduled_target_component_code": ( + "Колонка не может изменять саму себя." ) } ) - target_references = list( - PayrollCustomComponent.objects.filter( - is_active=True, - scheduled_target_component_code=code, - ).select_related("scheduled_application_period") + target_references = scheduled_components_targeting(code) + reference_periods = PayrollPeriod.objects.in_bulk( + { + reference.scheduled_application_period_id + for reference in target_references + if reference.scheduled_application_period_id is not None + } ) target_would_be_unavailable = ( values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE @@ -169,7 +171,10 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): elif values["period_scope"] == "current_and_future": target_would_be_unavailable = target_would_be_unavailable or any( scope_period is None - or reference.scheduled_application_period.date_from + or reference.scheduled_application_period_id not in reference_periods + or reference_periods[ + reference.scheduled_application_period_id + ].date_from < scope_period.date_from for reference in target_references ) @@ -337,19 +342,31 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): ) period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id")) mode = request.data.get("mode") - scheduled_references = PayrollCustomComponent.objects.filter( - is_active=True, - scheduled_target_component_code=code, + scheduled_references = scheduled_components_targeting(code) + reference_periods = PayrollPeriod.objects.in_bulk( + { + reference.scheduled_application_period_id + for reference in scheduled_references + if reference.scheduled_application_period_id is not None + } ) if mode == "current": - scheduled_references = scheduled_references.filter( - scheduled_application_period=period - ) + scheduled_references = [ + reference + for reference in scheduled_references + if reference.scheduled_application_period_id == period.pk + ] elif mode == "current_and_future": - scheduled_references = scheduled_references.filter( - scheduled_application_period__date_from__gte=period.date_from - ) - if scheduled_references.exists(): + scheduled_references = [ + reference + for reference in scheduled_references + if reference.scheduled_application_period_id in reference_periods + and reference_periods[ + reference.scheduled_application_period_id + ].date_from + >= period.date_from + ] + if scheduled_references: _conflict( "COMPONENT_HAS_SCHEDULED_CHANGES", ( diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 0b9e7de0..7c955085 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -213,13 +213,18 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: .order_by("display_order", "name", "id") ) if period is not None: - scope_period_ids = { + referenced_period_ids = { component.scope_period_id for component in builtins if component.scope_period_id is not None } - scope_dates = dict( - PayrollPeriod.objects.filter(pk__in=scope_period_ids).values_list( + referenced_period_ids.update( + component.scheduled_application_period_id + for component in builtins + if component.scheduled_application_period_id is not None + ) + period_dates = dict( + PayrollPeriod.objects.filter(pk__in=referenced_period_ids).values_list( "pk", "date_from" ) ) @@ -238,11 +243,22 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: or ( component.period_scope == PayrollComponentPeriodScope.CURRENT_AND_FUTURE - and scope_dates.get(component.scope_period_id, period.date_from) + and period_dates.get( + component.scope_period_id, + period.date_from, + ) <= period.date_from ) ) ) + and not ( + component.kind == PayrollComponentKind.SCHEDULED_CHANGE + and component.scheduled_application_period_id is not None + and period_dates.get(component.scheduled_application_period_id) + is not None + and period.date_from + >= period_dates[component.scheduled_application_period_id] + ) ] custom = [ component for component in custom if component.applies_to_period(period) diff --git a/backend/finance/payroll/scheduled_changes.py b/backend/finance/payroll/scheduled_changes.py index e0b9e1af..3e1eb3b1 100644 --- a/backend/finance/payroll/scheduled_changes.py +++ b/backend/finance/payroll/scheduled_changes.py @@ -30,25 +30,28 @@ def generated_source_line_id(line: PayrollInputLine) -> int | None: def scheduled_component_for_period(code: str, period: PayrollPeriod): - component = ( - PayrollCustomComponent.objects.select_related( - "scope_period", - "hidden_from_period", - "scheduled_application_period", - ) - .prefetch_related("excluded_periods") - .filter( - code=code, - is_active=True, - kind=PayrollComponentKind.SCHEDULED_CHANGE, - ) - .first() - ) - if component is None or not component.applies_to_period(period): + from finance.payroll.components import get_payroll_component + + try: + component = get_payroll_component(code, period=period) + except ValueError: + return None + if component.kind != PayrollComponentKind.SCHEDULED_CHANGE: return None return component +def scheduled_components_targeting(code: str): + from finance.payroll.components import get_payroll_components + + return [ + component + for component in get_payroll_components() + if component.kind == PayrollComponentKind.SCHEDULED_CHANGE + and component.scheduled_target_component_code == code + ] + + def employee_is_eligible_for_scheduled_change(employee, period: PayrollPeriod) -> bool: if not employee.is_active: return False @@ -105,6 +108,8 @@ def target_cell_scheduled_delta( def applied_change_payloads(lines: list[PayrollInputLine]) -> list[dict[str, object]]: + from finance.payroll.components import get_payroll_components + generated = [line for line in lines if is_generated_scheduled_change(line)] source_ids = { source_id @@ -117,11 +122,11 @@ 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 PayrollCustomComponent.objects.filter( - code__in={line.component_code for line in sources.values()} - ) + for component in get_payroll_components() + if component.code in source_codes } payloads = [] for generated_line in generated: diff --git a/backend/tests/finance/test_payroll_scheduled_changes.py b/backend/tests/finance/test_payroll_scheduled_changes.py index ccc829d3..a2a78ade 100644 --- a/backend/tests/finance/test_payroll_scheduled_changes.py +++ b/backend/tests/finance/test_payroll_scheduled_changes.py @@ -8,6 +8,7 @@ in one explicitly selected future period. import uuid from datetime import datetime +from decimal import Decimal import pytest from django.contrib.auth.models import Permission @@ -225,23 +226,63 @@ def test_scheduled_component_rejects_closed_application_period(scheduled_workspa assert "scheduled_application_period_id" in response.json() -def test_system_component_cannot_be_converted_to_a_scheduled_marker( +def test_empty_system_component_can_be_converted_to_a_scheduled_marker( scheduled_workspace, ): workspace = scheduled_workspace + client = workspace["client"] + source = workspace["periods"][6] + target = workspace["periods"][8] + employee = workspace["employee"] response = workspace["client"].patch( - api_url("admin-component-detail", code="BONUS"), + api_url("admin-component-detail", code="ADVANCE"), component_payload( - name="Премия", - source_period=workspace["periods"][6], - application_period=workspace["periods"][8], - target_component_code="VACATION_PAY", + name="Будущее изменение премии", + source_period=source, + application_period=target, ), format="json", ) - assert response.status_code == 400, response.content - assert "kind" in response.json() + assert response.status_code == 200, response.content + assert response.json()["kind"] == SCHEDULED_CHANGE_KIND + assert response.json()["is_custom"] is False + + PayrollInputLine.objects.create( + period=target, + employee=employee, + component_code="BONUS", + amount="1000.00", + created_by=workspace["manager"], + ) + saved = set_cell( + client, + period=source, + employee=employee, + component_code="ADVANCE", + amount="250.00", + ) + assert saved.status_code == 201, saved.content + + source_row = employee_row(period_table(client, source), employee) + assert source_row["component_amounts"]["ADVANCE"] == "250.00" + assert Decimal(source_row["payment_total"]) == Decimal("0") + target_row = employee_row(period_table(client, target), employee) + assert target_row["component_amounts"]["BONUS"] == "1250.00" + assert target_row["applied_scheduled_changes"] == [ + { + "target_component_code": "BONUS", + "amount": "250.00", + "source_period_id": source.pk, + "source_period_code": source.code, + "source_component_code": "ADVANCE", + "source_component_name": "Будущее изменение премии", + } + ] + assert all( + component["code"] != "ADVANCE" + for component in period_table(client, target)["component_columns"] + ) @pytest.mark.parametrize( diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index 93c9ba29..4156c89c 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -990,17 +990,11 @@ function PayrollComponentSettingsModal({ className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending} > - {Object.entries(kindLabels) - .filter( - ([value]) => - value !== "scheduled_change" || - component?.is_custom !== false, - ) - .map(([value, label]) => ( - - ))} + {Object.entries(kindLabels).map(([value, label]) => ( + + ))} {isScheduled ? (