fix(finance): defer markers until next period exists
All checks were successful
Deploy production / deploy (push) Successful in 10m58s
All checks were successful
Deploy production / deploy (push) Successful in 10m58s
This commit is contained in:
parent
38fa63e634
commit
63d77c38fa
@ -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:
|
||||
|
||||
@ -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=(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
33
backend/finance/migrations/0037_defer_scheduled_changes.py
Normal file
33
backend/finance/migrations/0037_defer_scheduled_changes.py
Normal 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",
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2227,7 +2227,6 @@ export function PayrollAdminWorkspace({
|
||||
<PayrollPeriodTableView
|
||||
compact
|
||||
data={periodTable}
|
||||
periods={workspace.periods}
|
||||
loading={tableLoading}
|
||||
error={tableError}
|
||||
search={search}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user