fix(finance): allow system columns as scheduled changes
All checks were successful
Deploy production / deploy (push) Successful in 15m7s
All checks were successful
Deploy production / deploy (push) Successful in 15m7s
This commit is contained in:
parent
bc6a1c78ca
commit
e308d5916b
@ -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",
|
||||
(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user