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 ? (