fix(finance): defer markers until next period exists
All checks were successful
Deploy production / deploy (push) Successful in 10m58s

This commit is contained in:
lizerk 2026-09-11 15:48:33 +03:00
parent 38fa63e634
commit 63d77c38fa
15 changed files with 363 additions and 371 deletions

View File

@ -9,7 +9,7 @@ from rest_framework.exceptions import ValidationError
from employees.constants import ACTION_DISMISSED
from employees.models import Employee
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, InputSource
from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind
from finance.models import (
PayrollAuditEvent,
PayrollInputCopyConfiguration,
@ -48,11 +48,15 @@ def _validate_input_copy_periods(target_period, source_period):
def _copyable_input_lines(source_period, *, target_period=None):
component_codes = {
component.code for component in get_payroll_components(source_period)
component.code
for component in get_payroll_components(source_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
if target_period is not None:
component_codes &= {
component.code for component in get_payroll_components(target_period)
component.code
for component in get_payroll_components(target_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
return (
PayrollInputLine.objects.filter(
@ -170,6 +174,7 @@ def _input_copy_preview(target_period, source_period, *, actor):
"amount_total": Decimal("0.00"),
}
for component in get_payroll_components(target_period)
if component.kind != PayrollComponentKind.SCHEDULED_CHANGE
}
employee_rows = {}
for line in lines:

View File

@ -19,7 +19,10 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.scheduled_changes import SCHEDULED_CHANGE_SOURCE_PREFIX
from finance.payroll.scheduled_changes import (
SCHEDULED_CHANGE_SOURCE_PREFIX,
materialize_scheduled_changes,
)
from finance.payroll.services import (
approve_input_line,
clear_component_input_lines,
@ -200,6 +203,10 @@ class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
actor=request.user,
data=data,
)
materialize_scheduled_changes(
source_period=source_period,
target_period=target_period,
)
return Response(
result,
status=(

View File

@ -30,7 +30,10 @@ 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 finance.payroll.scheduled_changes import (
materialize_scheduled_changes,
scheduled_components_targeting,
)
from ...admin_serializers import (
PayrollComponentSerializer,
@ -136,10 +139,6 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
scope_period = values.pop("scope_period", None)
scheduled_application_period = values.pop(
"scheduled_application_period",
None,
)
if (
values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE
and values.get("scheduled_target_component_code") == code
@ -154,28 +153,25 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
target_references = scheduled_components_targeting(code)
reference_periods = PayrollPeriod.objects.in_bulk(
{
reference.scheduled_application_period_id
reference.scope_period_id
for reference in target_references
if reference.scheduled_application_period_id is not None
if reference.scope_period_id is not None
}
)
target_would_be_unavailable = (
values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE
)
if values["period_scope"] == "current_only":
target_would_be_unavailable = target_would_be_unavailable or any(
reference.scheduled_application_period_id
!= getattr(scope_period, "pk", None)
for reference in target_references
target_would_be_unavailable = (
bool(target_references) or target_would_be_unavailable
)
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_id not in reference_periods
or reference_periods[
reference.scheduled_application_period_id
].date_from
< scope_period.date_from
or reference.scope_period_id is None
or reference.scope_period_id not in reference_periods
or scope_period.date_from
> reference_periods[reference.scope_period_id].date_from
for reference in target_references
)
if target_references and target_would_be_unavailable:
@ -186,17 +182,9 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
"Новые настройки должны сохранять её в периоде применения."
),
)
scheduled_route_changed = (
current.kind != values["kind"]
or getattr(current, "scheduled_target_component_code", "")
!= values.get("scheduled_target_component_code", "")
or getattr(current, "scheduled_application_period_id", None)
!= (
scheduled_application_period.pk
if scheduled_application_period is not None
else None
)
)
scheduled_route_changed = current.kind != values["kind"] or getattr(
current, "scheduled_target_component_code", ""
) != values.get("scheduled_target_component_code", "")
point_base_behavior_changed = current.include_in_point_base != values[
"include_in_point_base"
] or (values["include_in_point_base"] and current.kind != values["kind"])
@ -242,14 +230,12 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
for field_name, value in values.items():
setattr(custom, field_name, value)
custom.scope_period = scope_period
custom.scheduled_application_period = scheduled_application_period
try:
custom.full_clean()
custom.save(
update_fields=[
*values,
"scope_period",
"scheduled_application_period",
]
)
except DjangoValidationError as exc:
@ -262,12 +248,8 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
overrides[code] = {
**values,
"scope_period_id": scope_period.pk if scope_period else None,
"scheduled_application_period_id": (
scheduled_application_period.pk
if scheduled_application_period
else None
),
}
overrides[code].pop("scheduled_application_period_id", None)
settings.payroll_component_overrides = overrides
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
audit_instance = settings
@ -343,29 +325,6 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id"))
mode = request.data.get("mode")
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 = [
reference
for reference in scheduled_references
if reference.scheduled_application_period_id == period.pk
]
elif mode == "current_and_future":
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",
@ -452,45 +411,49 @@ class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
instance=period,
period=period,
)
source_period = (
PayrollPeriod.objects.select_for_update(of=("self",))
.filter(
currency=period.currency,
date_to__lt=period.date_from,
)
.exclude(pk=period.pk)
.order_by("-date_to", "-id")
.first()
)
automatic_copy = None
configuration = PayrollInputCopyConfiguration.objects.filter(
owner=request.user,
auto_apply_on_period_creation=True,
).first()
if configuration is not None:
source_period = (
PayrollPeriod.objects.select_for_update(of=("self",))
.filter(
currency=period.currency,
date_to__lt=period.date_from,
)
.exclude(pk=period.pk)
.order_by("-date_to", "-id")
.first()
if configuration is not None and source_period is not None:
automatic_copy = _apply_input_copy(
target_period=period,
source_period=source_period,
actor=request.user,
data={
"component_codes": configuration.component_codes,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"idempotency_key": uuid.uuid4(),
},
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_input_copy_auto_applied",
instance=period,
period=period,
metadata={
"source_period_id": source_period.pk,
**automatic_copy["summary"],
},
)
if source_period is not None:
materialize_scheduled_changes(
source_period=source_period,
target_period=period,
)
if source_period is not None:
automatic_copy = _apply_input_copy(
target_period=period,
source_period=source_period,
actor=request.user,
data={
"component_codes": configuration.component_codes,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"idempotency_key": uuid.uuid4(),
},
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_input_copy_auto_applied",
instance=period,
period=period,
metadata={
"source_period_id": source_period.pk,
**automatic_copy["summary"],
},
)
payload = PayrollPeriodSerializer(period).data
payload["automatic_input_copy"] = automatic_copy
return Response(

View File

@ -20,6 +20,7 @@ from finance.enums import (
)
from finance.models import PayrollCustomComponent, PayrollPeriod
from finance.payroll.components import get_payroll_component
from finance.payroll.scheduled_changes import next_payroll_period
Employee = get_user_model()
@ -116,10 +117,6 @@ class PayrollComponentSerializer(serializers.Serializer):
read_only=True,
allow_blank=True,
)
scheduled_application_period_id = serializers.IntegerField(
read_only=True,
allow_null=True,
)
def get_id(self, obj) -> str:
return obj.code
@ -186,34 +183,11 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"period_id": "Выберите исходный расчётный период."}
)
application_period = (
PayrollPeriod.objects.filter(date_from__gt=source_period.date_from)
.order_by("date_from", "pk")
.first()
)
if application_period is None:
raise serializers.ValidationError(
{
"period_id": (
"Следующий расчётный период ещё не создан. "
"Сначала создайте его."
)
}
)
if application_period.status == "closed":
raise serializers.ValidationError(
{
"period_id": (
"Следующий расчётный период уже закрыт. "
"Запланированное изменение создать нельзя."
)
}
)
attrs["scheduled_application_period"] = application_period
target_period = next_payroll_period(source_period) or source_period
try:
target = get_payroll_component(
target_code,
period=application_period,
period=target_period,
)
except ValueError as exc:
raise serializers.ValidationError(
@ -231,9 +205,20 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
)
}
)
if (
target_period.pk == source_period.pk
and target.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
):
raise serializers.ValidationError(
{
"scheduled_target_component_code": (
"Колонка назначения должна быть доступна "
"в следующем периоде."
)
}
)
else:
attrs["scheduled_target_component_code"] = ""
attrs["scheduled_application_period"] = None
return attrs

View File

@ -0,0 +1,33 @@
from django.db import migrations
def remove_obsolete_application_periods(apps, schema_editor):
finance_settings = apps.get_model("finance", "FinanceSettings")
for settings in finance_settings.objects.all().iterator():
overrides = dict(settings.payroll_component_overrides or {})
changed = False
for code, override in overrides.items():
if not isinstance(override, dict):
continue
if "scheduled_application_period_id" in override:
overrides[code] = dict(override)
overrides[code].pop("scheduled_application_period_id", None)
changed = True
if changed:
settings.payroll_component_overrides = overrides
settings.save(update_fields=["payroll_component_overrides"])
class Migration(migrations.Migration):
dependencies = [("finance", "0036_scheduled_component_changes")]
operations = [
migrations.RunPython(
remove_obsolete_application_periods,
migrations.RunPython.noop,
),
migrations.RemoveField(
model_name="payrollcustomcomponent",
name="scheduled_application_period",
),
]

View File

@ -1031,14 +1031,6 @@ class PayrollCustomComponent(models.Model):
validators=[component_code_validator],
help_text="Заполняется только для запланированного изменения.",
)
scheduled_application_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="scheduled_component_changes",
verbose_name="Период применения",
)
is_active = models.BooleanField("Активна", default=True)
display_order = models.PositiveIntegerField("Порядок", default=900)
period_scope = models.CharField(
@ -1116,46 +1108,18 @@ class PayrollCustomComponent(models.Model):
raise ValidationError(
{"scheduled_target_component_code": "Выберите колонку назначения."}
)
if self.scheduled_application_period_id is None:
raise ValidationError(
{
"scheduled_application_period": (
"Следующий расчётный период не определён."
)
}
)
if (
self.scope_period_id
and self.scheduled_application_period.date_from
<= self.scope_period.date_from
):
raise ValidationError(
{
"scheduled_application_period": (
"Следующий период должен быть позже начального периода."
)
}
)
if self.scheduled_application_period.status == PayrollPeriodStatus.CLOSED:
raise ValidationError(
{
"scheduled_application_period": (
"Следующий расчётный период уже закрыт."
)
}
)
try:
from finance.payroll.components import get_payroll_component
target = get_payroll_component(
self.scheduled_target_component_code,
period=self.scheduled_application_period,
period=self.scope_period,
)
except ValueError as exc:
raise ValidationError(
{
"scheduled_target_component_code": (
"Колонка назначения недоступна в периоде применения."
"Колонка назначения недоступна в начальном периоде."
)
}
) from exc
@ -1170,10 +1134,7 @@ class PayrollCustomComponent(models.Model):
self.include_in_point_base = False
self.include_in_carryover = False
self.visible_to_employee = False
elif (
self.scheduled_target_component_code
or self.scheduled_application_period_id is not None
):
elif self.scheduled_target_component_code:
raise ValidationError(
{
"scheduled_target_component_code": (
@ -1247,12 +1208,6 @@ class PayrollCustomComponent(models.Model):
return True
if period is None or self.scope_period_id is None:
return False
if (
self.kind == PayrollComponentKind.SCHEDULED_CHANGE
and self.scheduled_application_period_id
and period.date_from >= self.scheduled_application_period.date_from
):
return False
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return period.pk == self.scope_period_id
return period.date_from >= self.scope_period.date_from

View File

@ -20,7 +20,6 @@ class PayrollComponentDefinition:
is_custom: bool = False
highlight_color: str = ""
scheduled_target_component_code: str = ""
scheduled_application_period_id: int | None = None
@property
def id(self) -> str:
@ -195,10 +194,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
"scheduled_target_component_code",
component.scheduled_target_component_code,
),
scheduled_application_period_id=override.get(
"scheduled_application_period_id",
component.scheduled_application_period_id,
),
)
)
@ -207,7 +202,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
.select_related(
"scope_period",
"hidden_from_period",
"scheduled_application_period",
)
.prefetch_related("excluded_periods")
.order_by("display_order", "name", "id")
@ -218,11 +212,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
for component in builtins
if component.scope_period_id is not None
}
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"
@ -251,14 +240,6 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
)
)
)
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

@ -191,7 +191,6 @@ def _component_definition(
is_custom=False,
highlight_color="",
scheduled_target_component_code="",
scheduled_application_period_id=None,
):
return {
"code": code,
@ -207,7 +206,6 @@ def _component_definition(
"is_custom": is_custom,
"highlight_color": highlight_color,
"scheduled_target_component_code": scheduled_target_component_code,
"scheduled_application_period_id": scheduled_application_period_id,
}
@ -414,7 +412,6 @@ def build_payroll_period_table(period: PayrollPeriod):
is_custom=component.is_custom,
highlight_color=component.highlight_color,
scheduled_target_component_code=(component.scheduled_target_component_code),
scheduled_application_period_id=(component.scheduled_application_period_id),
)
for component in components
}

View File

@ -8,13 +8,106 @@ from django.db.models import Q
from employees.constants import ACTION_DISMISSED
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import ApprovalStatus, PayrollComponentKind
from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind
from finance.models import PayrollCustomComponent, PayrollInputLine, PayrollPeriod
from requests_app.enums import RequestStatus
SCHEDULED_CHANGE_SOURCE_PREFIX = "payroll-scheduled-change:"
def next_payroll_period(source_period: PayrollPeriod, *, for_update: bool = False):
"""Return the nearest later period in the same payroll timeline."""
periods = PayrollPeriod.objects.filter(
currency=source_period.currency,
date_from__gt=source_period.date_to,
).order_by("date_from", "pk")
if for_update:
periods = periods.select_for_update()
return periods.first()
def materialize_scheduled_changes(
*,
source_period: PayrollPeriod,
target_period: PayrollPeriod,
) -> list[PayrollInputLine]:
"""Apply source markers once when their immediately next period exists."""
next_period = next_payroll_period(source_period, for_update=True)
if next_period is None or next_period.pk != target_period.pk:
return []
from finance.payroll.components import get_payroll_component, get_payroll_components
scheduled_components = {
component.code: component
for component in get_payroll_components(source_period)
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE
}
if not scheduled_components:
return []
target_components = {
component.code: component for component in get_payroll_components(target_period)
}
source_lines = list(
PayrollInputLine.objects.select_for_update()
.select_related("created_by", "approved_by")
.filter(
period=source_period,
component_code__in=scheduled_components,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
existing_refs = set(
PayrollInputLine.objects.filter(
source=InputSource.API,
source_ref__in=[
f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}" for line in source_lines
],
).values_list("source_ref", flat=True)
)
generated = []
for source_line in source_lines:
source_ref = f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{source_line.pk}"
if source_ref in existing_refs:
continue
scheduled_component = scheduled_components[source_line.component_code]
target_code = scheduled_component.scheduled_target_component_code
target = target_components.get(target_code)
if target is None:
target = get_payroll_component(target_code, period=target_period)
if target.kind == PayrollComponentKind.SCHEDULED_CHANGE:
raise ValueError(
"Запланированное изменение не может изменять другой маркер."
)
line = PayrollInputLine(
period=target_period,
employee_id=source_line.employee_id,
component_code=target_code,
amount=source_line.amount,
value_sign=source_line.value_sign,
reason=(
f"Применено из колонки «{scheduled_component.name}» "
f"периода {source_period.code}"
),
status=source_line.status,
source=InputSource.API,
source_ref=source_ref,
include_in_carryover=False,
created_by=source_line.created_by,
approved_by=source_line.approved_by,
approved_at=source_line.approved_at,
self_approval_overridden=source_line.self_approval_overridden,
)
line.full_clean()
line.save(force_insert=True)
generated.append(line)
return generated
def is_generated_scheduled_change(line: PayrollInputLine) -> bool:
return line.source_ref.startswith(SCHEDULED_CHANGE_SOURCE_PREFIX)

View File

@ -64,6 +64,8 @@ from .scheduled_changes import (
SCHEDULED_CHANGE_SOURCE_PREFIX,
employee_is_eligible_for_scheduled_change,
is_generated_scheduled_change,
materialize_scheduled_changes,
next_payroll_period,
scheduled_component_for_period,
target_cell_scheduled_delta,
)
@ -1988,21 +1990,10 @@ def clear_component_input_lines(
if employee is None:
_operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.")
scheduled_component = scheduled_component_for_period(component_code, period)
if scheduled_component is not None:
target_period = PayrollPeriod.objects.select_for_update().get(
pk=scheduled_component.scheduled_application_period_id
)
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
_operation_error(
"TARGET_PERIOD_INPUTS_LOCKED",
"Период применения уже заблокирован для изменений.",
application_period_id=target_period.pk,
original_code=exc.code,
)
elif validate_scheduled_result and employee_is_eligible_for_scheduled_change(
employee, period
if (
scheduled_component is None
and validate_scheduled_result
and employee_is_eligible_for_scheduled_change(employee, period)
):
scheduled_delta = target_cell_scheduled_delta(
period=period,
@ -2043,6 +2034,16 @@ def clear_component_input_lines(
)
.order_by("pk")
)
for target_period in {generated.period for generated in generated_lines}:
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
_operation_error(
"TARGET_PERIOD_INPUTS_LOCKED",
"Период применения уже заблокирован для изменений.",
application_period_id=target_period.pk,
original_code=exc.code,
)
now = timezone.now()
summary = {"draft": 0, "approved": 0, "total": len(lines)}
for line in lines:
@ -2099,6 +2100,19 @@ def set_component_input_line_value(
details={"component_code": component_code},
) from exc
scheduled_component = scheduled_component_for_period(component_code, period)
target_period = None
if scheduled_component is not None:
target_period = next_payroll_period(period, for_update=True)
if target_period is not None:
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
_operation_error(
"TARGET_PERIOD_INPUTS_LOCKED",
"Следующий период уже заблокирован для изменений.",
application_period_id=target_period.pk,
original_code=exc.code,
)
signed_amount = Decimal(amount)
if signed_amount == 0:
_operation_error(
@ -2157,29 +2171,16 @@ def set_component_input_line_value(
)
},
) from exc
if scheduled_component is not None:
target_period = PayrollPeriod.objects.select_for_update().get(
pk=scheduled_component.scheduled_application_period_id
)
generated = PayrollInputLine(
period=target_period,
employee_id=employee_id,
component_code=scheduled_component.scheduled_target_component_code,
amount=line.amount,
value_sign=line.value_sign,
reason=(
f"Применено из колонки «{scheduled_component.name}» "
f"периода {period.code}"
),
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}",
include_in_carryover=False,
created_by=actor,
)
if scheduled_component is not None and target_period is not None:
try:
generated.full_clean()
generated.save(force_insert=True)
materialize_scheduled_changes(
source_period=period,
target_period=target_period,
)
generated = PayrollInputLine.objects.get(
source=InputSource.API,
source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}",
)
except ValidationError as exc:
raise PayrollOperationError(
"INPUT_VALIDATION_FAILED",
@ -2346,11 +2347,9 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]:
for line in input_lines:
component = scheduled_component_for_period(line.component_code, period)
if component is not None:
target_period = next(
item
for item in periods
if item.pk == component.scheduled_application_period_id
)
target_period = next_payroll_period(period)
if target_period is None:
continue
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
@ -2492,18 +2491,17 @@ def approve_input_line(
)
component = scheduled_component_for_period(record.component_code, period)
if component is not None:
target_period = PayrollPeriod.objects.select_for_update().get(
pk=component.scheduled_application_period_id
)
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
_operation_error(
"TARGET_PERIOD_INPUTS_LOCKED",
"Период применения уже заблокирован для изменений.",
application_period_id=target_period.pk,
original_code=exc.code,
)
target_period = next_payroll_period(period, for_update=True)
if target_period is not None:
try:
_ensure_period_accepts_approvals(target_period)
except PayrollOperationError as exc:
_operation_error(
"TARGET_PERIOD_INPUTS_LOCKED",
"Период применения уже заблокирован для изменений.",
application_period_id=target_period.pk,
original_code=exc.code,
)
approved_line = _approve_record(
record,
actor=actor,

View File

@ -214,7 +214,6 @@ def test_manager_can_customize_system_component_behavior(
"is_custom": False,
"highlight_color": "violet",
"scheduled_target_component_code": "",
"scheduled_application_period_id": None,
}
override = FinanceSettings.get_solo().payroll_component_overrides["BONUS"]
assert override["include_in_carryover"] is True

View File

@ -152,7 +152,7 @@ def scheduled_workspace(user_factory, auth_client_factory):
}
def test_scheduled_component_uses_the_immediately_next_period_automatically(
def test_scheduled_component_stores_a_rule_without_binding_a_future_period(
scheduled_workspace,
):
workspace = scheduled_workspace
@ -160,7 +160,6 @@ def test_scheduled_component_uses_the_immediately_next_period_automatically(
name="Плановое изменение премии",
source_period=workspace["periods"][7],
)
payload["scheduled_application_period_id"] = workspace["periods"][9].pk
response = workspace["client"].post(
api_url("admin-component-list"),
payload,
@ -171,7 +170,7 @@ def test_scheduled_component_uses_the_immediately_next_period_automatically(
assert component["kind"] == SCHEDULED_CHANGE_KIND
assert component["scheduled_target_component_code"] == "BONUS"
assert component["scheduled_application_period_id"] == workspace["periods"][8].pk
assert "scheduled_application_period_id" not in component
assert component["period_scope"] == "current_and_future"
assert component["scope_period_id"] == workspace["periods"][7].pk
assert component["include_in_point_base"] is False
@ -195,38 +194,70 @@ def test_scheduled_component_rejects_missing_target(scheduled_workspace):
assert "scheduled_target_component_code" in response.json()
def test_scheduled_component_requires_a_next_period(scheduled_workspace):
def test_marker_waits_until_the_next_period_is_created(scheduled_workspace):
workspace = scheduled_workspace
response = workspace["client"].post(
api_url("admin-component-list"),
component_payload(
name="Маркер без следующего периода",
source_period=workspace["periods"][9],
),
client = workspace["client"]
employee = workspace["employee"]
source = workspace["periods"][9]
component = create_scheduled_component(
client,
name="Маркер без следующего периода",
source_period=source,
)
saved = set_cell(
client,
period=source,
employee=employee,
component_code=component["code"],
amount="500.00",
)
assert saved.status_code == 201, saved.content
assert not PayrollInputLine.objects.filter(
source_ref__startswith="payroll-scheduled-change:"
).exists()
created = client.post(
api_url("admin-period-list"),
{
"code": "2026-10",
"name": "Октябрь 2026",
"date_from": "2026-10-01",
"date_to": "2026-10-31",
"pay_date": "2026-11-05",
"currency": "RUB",
},
format="json",
)
assert response.status_code == 400, response.content
assert "period_id" in response.json()
assert created.status_code == 201, created.content
target = PayrollPeriod.objects.get(pk=created.json()["id"])
row = employee_row(period_table(client, target), employee)
assert row["component_amounts"]["BONUS"] == "500.00"
assert len(row["applied_scheduled_changes"]) == 1
def test_scheduled_component_rejects_closed_application_period(scheduled_workspace):
def test_closed_next_period_rejects_marker_value(scheduled_workspace):
workspace = scheduled_workspace
target = workspace["periods"][8]
target.status = "closed"
target.save(update_fields=["status"])
response = workspace["client"].post(
api_url("admin-component-list"),
component_payload(
name="Маркер в закрытый период",
source_period=workspace["periods"][7],
),
format="json",
component = create_scheduled_component(
workspace["client"],
name="Маркер в закрытый период",
source_period=workspace["periods"][7],
)
response = set_cell(
workspace["client"],
period=workspace["periods"][7],
employee=workspace["employee"],
component_code=component["code"],
amount="500.00",
)
assert response.status_code == 400, response.content
assert "period_id" in response.json()
assert response.status_code == 409, response.content
assert response.json()["code"] == "TARGET_PERIOD_INPUTS_LOCKED"
def test_empty_system_component_can_be_converted_to_a_scheduled_marker(
@ -281,8 +312,8 @@ def test_empty_system_component_can_be_converted_to_a_scheduled_marker(
"source_component_name": "Будущее изменение премии",
}
]
assert all(
component["code"] != "ADVANCE"
assert any(
component["code"] == "ADVANCE"
for component in period_table(client, target)["component_columns"]
)
@ -525,7 +556,7 @@ def test_multiple_marker_columns_are_summed_in_the_next_period(
)
def test_marker_column_is_visible_only_in_its_source_period(
def test_marker_column_is_available_from_its_source_period_onward(
scheduled_workspace,
):
workspace = scheduled_workspace
@ -546,10 +577,10 @@ def test_marker_column_is_visible_only_in_its_source_period(
assert component["code"] not in {
column["code"] for column in period_table(client, previous)["component_columns"]
}
assert component["code"] not in {
assert component["code"] in {
column["code"] for column in period_table(client, target)["component_columns"]
}
assert component["code"] not in {
assert component["code"] in {
column["code"]
for column in period_table(client, following)["component_columns"]
}
@ -586,7 +617,7 @@ def test_reentering_same_cell_replaces_its_net_value_instead_of_stacking(
assert len(target_row["applied_scheduled_changes"]) == 1
def test_marker_value_cannot_be_entered_in_or_after_application_period(
def test_each_period_marker_changes_only_its_immediately_next_period(
scheduled_workspace,
):
workspace = scheduled_workspace
@ -594,22 +625,28 @@ def test_marker_value_cannot_be_entered_in_or_after_application_period(
employee = workspace["employee"]
source = workspace["periods"][7]
target = workspace["periods"][8]
following = workspace["periods"][9]
component = create_scheduled_component(
client,
name="Маркер только до назначения",
name="Повторно используемый маркер",
source_period=source,
)
for period in (target, workspace["periods"][9]):
rejected = set_cell(
client,
period=period,
employee=employee,
component_code=component["code"],
amount="500.00",
)
assert rejected.status_code == 409, rejected.content
assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE"
saved = set_cell(
client,
period=target,
employee=employee,
component_code=component["code"],
amount="500.00",
)
assert saved.status_code == 201, saved.content
target_row = employee_row(period_table(client, target), employee)
assert target_row["component_amounts"][component["code"]] == "500.00"
assert "BONUS" not in target_row["component_amounts"]
following_row = employee_row(period_table(client, following), employee)
assert following_row["component_amounts"]["BONUS"] == "500.00"
assert len(following_row["applied_scheduled_changes"]) == 1
@pytest.mark.parametrize("existing_mode", ["skip", "replace"])
@ -723,10 +760,11 @@ def test_adjusted_value_copies_normally_later_without_copying_marker_badge(
following_row = employee_row(period_table(client, following), employee)
assert following_row["component_amounts"]["BONUS"] == "1500.00"
assert following_row["applied_scheduled_changes"] == []
assert component["code"] not in {
assert component["code"] in {
column["code"]
for column in period_table(client, following)["component_columns"]
}
assert component["code"] not in following_row["component_amounts"]
def test_editing_and_clearing_marker_recalculates_open_target_once(
@ -779,20 +817,21 @@ def test_editing_and_clearing_marker_recalculates_open_target_once(
assert row["applied_scheduled_changes"] == []
def test_automatic_copy_carries_effective_value_but_not_marker(scheduled_workspace):
def test_period_creation_copies_then_applies_pending_marker_once(
scheduled_workspace,
):
workspace = scheduled_workspace
client = workspace["client"]
manager = workspace["manager"]
employee = workspace["employee"]
source = workspace["periods"][7]
applied = workspace["periods"][8]
source = workspace["periods"][8]
component = create_scheduled_component(
client,
name="Маркер перед автопереносом",
source_period=source,
)
PayrollInputLine.objects.create(
period=applied,
period=source,
employee=employee,
component_code="BONUS",
amount="1000.00",
@ -834,11 +873,12 @@ def test_automatic_copy_carries_effective_value_but_not_marker(scheduled_workspa
following = PayrollPeriod.objects.get(pk=created.json()["id"])
row = employee_row(period_table(client, following), employee)
assert row["component_amounts"]["BONUS"] == "1500.00"
assert row["applied_scheduled_changes"] == []
assert component["code"] not in {
assert len(row["applied_scheduled_changes"]) == 1
assert component["code"] in {
column["code"]
for column in period_table(client, following)["component_columns"]
}
assert component["code"] not in row["component_amounts"]
def test_marker_is_not_applied_to_employee_dismissed_before_target(
@ -1000,7 +1040,7 @@ def test_target_column_cannot_be_hidden_while_a_marker_references_it(
assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES"
def test_target_column_cannot_be_scoped_away_from_application_period(
def test_target_column_cannot_be_scoped_away_from_future_periods(
scheduled_workspace,
):
workspace = scheduled_workspace
@ -1042,7 +1082,6 @@ def test_target_column_cannot_be_scoped_away_from_application_period(
"period_scope": "current_only",
"period_id": source.pk,
"scheduled_target_component_code": "",
"scheduled_application_period_id": None,
},
format="json",
)
@ -1051,7 +1090,7 @@ def test_target_column_cannot_be_scoped_away_from_application_period(
assert blocked.json()["code"] == "COMPONENT_HAS_SCHEDULED_CHANGES"
def test_marker_rejects_target_column_unavailable_in_application_period(
def test_marker_rejects_target_column_unavailable_in_the_next_period(
scheduled_workspace,
):
workspace = scheduled_workspace

View File

@ -2227,7 +2227,6 @@ export function PayrollAdminWorkspace({
<PayrollPeriodTableView
compact
data={periodTable}
periods={workspace.periods}
loading={tableLoading}
error={tableError}
search={search}

View File

@ -51,7 +51,6 @@ import {
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollAdminPeriod,
PayrollComponentPeriodScope,
PayrollColumnHighlightColor,
PayrollCustomComponentWrite,
@ -61,11 +60,7 @@ import type {
PayrollPeriodTableRow,
PayrollPeriodTableStatus,
} from "@/lib/api/finance";
import {
formatPayrollMoney,
formatPayrollPoints,
getPayrollPeriodRange,
} from "@/lib/payroll";
import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { assessPayrollPointBaseChange } from "@/lib/payroll-point-base";
import {
@ -727,8 +722,6 @@ function PayrollComponentSettingsModal({
component,
rows,
components,
periods,
currentPeriodId,
currency,
onSave,
onDelete,
@ -737,8 +730,6 @@ function PayrollComponentSettingsModal({
component?: PayrollPeriodTableComponent | null;
rows: PayrollPeriodTableRow[];
components: PayrollPeriodTableComponent[];
periods: PayrollAdminPeriod[];
currentPeriodId: number;
currency: string;
onSave: (payload: PayrollCustomComponentWrite) => Promise<void>;
onDelete?: (mode: "current" | "current_and_future") => Promise<void>;
@ -796,19 +787,6 @@ function PayrollComponentSettingsModal({
};
}, [onClose, pending]);
const currentPeriod = periods.find((item) => item.id === currentPeriodId);
const nextPeriod = periods
.filter(
(item) =>
currentPeriod &&
new Date(item.date_from).getTime() >
new Date(currentPeriod.date_from).getTime(),
)
.sort(
(left, right) =>
new Date(left.date_from).getTime() -
new Date(right.date_from).getTime() || left.id - right.id,
)[0];
const scheduledTargets = components.filter(
(item) => item.code !== component?.code && item.kind !== "scheduled_change",
);
@ -825,14 +803,6 @@ function PayrollComponentSettingsModal({
setError("Выберите колонку, значение которой нужно изменить.");
return;
}
if (isScheduled && !nextPeriod) {
setError("Следующий расчётный период ещё не создан.");
return;
}
if (isScheduled && nextPeriod.status === "closed") {
setError("Следующий расчётный период уже закрыт.");
return;
}
if (!isScheduled && pointBaseAssessment.negative.length) {
setError(
"Сохранение заблокировано: настройка создаёт отрицательную балльную базу.",
@ -898,14 +868,10 @@ function PayrollComponentSettingsModal({
is_custom: component?.is_custom ?? true,
highlight_color: highlightColor,
scheduled_target_component_code: isScheduled ? scheduledTargetCode : "",
scheduled_application_period_id: isScheduled
? nextPeriod?.id || null
: null,
};
const calculationPreview = componentCalculationHelp(
previewComponent,
components,
periods,
);
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
code: component?.code || "PREVIEW",
@ -1021,25 +987,12 @@ function PayrollComponentSettingsModal({
</label>
<div className="app-surface rounded-lg px-3 py-2.5">
<span className="app-field-label">Когда применится</span>
{nextPeriod ? (
<p className="mt-1 text-sm text-[var(--foreground)]">
Автоматически в следующем периоде —{" "}
<span className="font-medium">
{nextPeriod.name || nextPeriod.code}
</span>
<span className="app-text-muted mt-0.5 block text-xs">
{getPayrollPeriodRange(nextPeriod)}
{nextPeriod.status === "closed"
? " · период уже закрыт"
: ""}
</span>
</p>
) : (
<p className="app-feedback-warning mt-1 text-xs">
Следующий расчётный период ещё не создан. Сначала создайте
его.
</p>
)}
<p className="mt-1 text-sm text-[var(--foreground)]">
Автоматически при создании следующего расчётного периода
</p>
<p className="app-text-muted mt-0.5 text-xs">
Создавать следующий период заранее не требуется.
</p>
</div>
<p className="app-text-muted text-xs leading-relaxed">
Маркер допускает плюс и минус, не участвует в текущем расчёте и
@ -1548,18 +1501,13 @@ function calculationHelps(
function componentCalculationHelp(
component: PayrollPeriodTableComponent,
components: PayrollPeriodTableComponent[] = [],
periods: PayrollAdminPeriod[] = [],
): CalculationHelp {
if (component.kind === "scheduled_change") {
const target = components.find(
(item) => item.code === component.scheduled_target_component_code,
);
const applicationPeriod = periods.find(
(item) => item.id === component.scheduled_application_period_id,
);
const targetLabel = target?.label || "выбранная колонка";
const periodLabel =
applicationPeriod?.name || applicationPeriod?.code || "следующий период";
const periodLabel = "следующий период";
return {
title: component.label,
logic: `Это внутренний маркер. В текущем периоде он не меняет расчёт и не показывается сотруднику. В периоде «${periodLabel}» сумма один раз прибавится к колонке «${targetLabel}»; отрицательная сумма уменьшит её. Несколько маркеров складываются.`,
@ -1980,7 +1928,6 @@ type CompactPeriodPanel = "summary" | "table" | "help";
export function PayrollPeriodTableView({
data,
periods = [],
loading,
error,
search,
@ -2003,7 +1950,6 @@ export function PayrollPeriodTableView({
compact = false,
}: {
data: PayrollPeriodTable | null;
periods?: PayrollAdminPeriod[];
loading: boolean;
error: string | null;
search: string;
@ -2423,11 +2369,7 @@ export function PayrollPeriodTableView({
>
<ColumnHeader
label={component.label}
help={componentCalculationHelp(
component,
data.component_columns,
periods,
)}
help={componentCalculationHelp(component, data.component_columns)}
onConfigure={
onUpdateComponent
? () => {
@ -3847,8 +3789,6 @@ export function PayrollPeriodTableView({
component={editingComponent}
rows={data.rows}
components={data.component_columns}
periods={periods}
currentPeriodId={data.period_id}
currency={data.currency}
onSave={(payload) =>
editingComponent && onUpdateComponent

View File

@ -402,7 +402,6 @@ export type PayrollPeriodTableComponent = {
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
scheduled_target_component_code: string;
scheduled_application_period_id: number | null;
};
export type PayrollCellCommentAuthor = {
@ -637,7 +636,6 @@ export type PayrollComponent = {
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
scheduled_target_component_code: string;
scheduled_application_period_id: number | null;
};
export type PayrollColumnHighlightColor =