fix(finance): allow system columns as scheduled changes
All checks were successful
Deploy production / deploy (push) Successful in 15m7s

This commit is contained in:
lizerk 2026-09-11 14:21:14 +03:00
parent bc6a1c78ca
commit e308d5916b
5 changed files with 135 additions and 62 deletions

View File

@ -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",
(

View File

@ -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)

View File

@ -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:

View File

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

View File

@ -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]) => (
<option key={value} value={value}>
{label}
</option>
))}
{Object.entries(kindLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{isScheduled ? (