feat(finance): add scheduled payroll changes
All checks were successful
Deploy production / deploy (push) Successful in 10m1s

This commit is contained in:
lizerk 2026-09-11 12:55:33 +03:00
parent d38170640b
commit bc6a1c78ca
21 changed files with 2598 additions and 100 deletions

View File

@ -16,6 +16,10 @@ from finance.models import (
PayrollInputLine,
)
from finance.payroll.components import get_payroll_components
from finance.payroll.scheduled_changes import (
employee_is_eligible_for_scheduled_change,
is_generated_scheduled_change,
)
from ..admin_serializers import (
PayrollComponentSerializer,
@ -86,6 +90,45 @@ def _employee_active_for_period(employee, period):
return state.status != ACTION_DISMISSED
def _collapse_scheduled_effective_cells(lines):
"""Fold a marker-adjusted source cell into one ordinary copied value."""
lines = [
line
for line in lines
if not is_generated_scheduled_change(line)
or employee_is_eligible_for_scheduled_change(line.employee, line.period)
]
grouped = {}
for line in lines:
grouped.setdefault((line.employee_id, line.component_code), []).append(line)
result = []
for (employee_id, component_code), cell_lines in grouped.items():
if not any(is_generated_scheduled_change(line) for line in cell_lines):
result.extend(cell_lines)
continue
amount = sum((line.signed_amount for line in cell_lines), Decimal("0"))
if amount <= 0:
continue
source_ids = ",".join(str(line.pk) for line in cell_lines)
effective = PayrollInputLine(
period=cell_lines[0].period,
employee=cell_lines[0].employee,
component_code=component_code,
amount=amount,
value_sign=1,
relates_to_period=None,
reason="Перенесено итоговое значение с применённым маркером",
include_in_carryover=any(line.include_in_carryover for line in cell_lines),
created_by=cell_lines[0].created_by,
)
effective._copy_source_token = (
f"effective-{employee_id}-{component_code}-{source_ids}"
)
result.append(effective)
return result
def _input_copy_configuration_payload(actor):
configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first()
if configuration is None:
@ -116,7 +159,9 @@ def _input_copy_configuration_payload(actor):
def _input_copy_preview(target_period, source_period, *, actor):
_validate_input_copy_periods(target_period, source_period)
lines = list(_copyable_input_lines(source_period, target_period=target_period))
lines = _collapse_scheduled_effective_cells(
list(_copyable_input_lines(source_period, target_period=target_period))
)
component_rows = {
component.code: {
"component": component,
@ -240,6 +285,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
target_period=target_period,
).filter(component_code__in=data["component_codes"])
)
lines = _collapse_scheduled_effective_cells(lines)
if data["employee_scope"] == "selected":
selected_ids = set(data["employee_ids"])
lines = [line for line in lines if line.employee_id in selected_ids]
@ -268,6 +314,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
line
for line in possible_existing_lines
if (line.employee_id, line.component_code) in selected_cells
and not is_generated_scheduled_change(line)
and not (
line.source == InputSource.API
and line.source_ref.startswith("payroll-carryover:")
@ -324,11 +371,14 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
employee=line.employee,
component_code=line.component_code,
amount=line.amount,
value_sign=line.value_sign,
relates_to_period=line.relates_to_period,
reason=line.reason,
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=f"{copy_prefix}{line.pk}",
source_ref=(
f"{copy_prefix}{getattr(line, '_copy_source_token', line.pk)}"
),
include_in_carryover=line.include_in_carryover,
created_by=actor,
)

View File

@ -19,6 +19,7 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.scheduled_changes import SCHEDULED_CHANGE_SOURCE_PREFIX
from finance.payroll.services import (
approve_input_line,
clear_component_input_lines,
@ -67,6 +68,7 @@ class PayrollInputLineListCreateView(DraftCollectionView):
"created_by",
"approved_by",
)
.exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX)
.prefetch_related("employee__departments_links__department")
.order_by("-created_at", "-id")
)
@ -94,14 +96,18 @@ class PayrollInputLineDetailView(DraftDetailView):
immutable_patch_fields = ("period_id", "employee_id", "component_code")
def get_queryset(self):
return PayrollInputLine.objects.select_related(
"period",
"employee",
"employee__position",
"relates_to_period",
"created_by",
"approved_by",
).prefetch_related("employee__departments_links__department")
return (
PayrollInputLine.objects.select_related(
"period",
"employee",
"employee__position",
"relates_to_period",
"created_by",
"approved_by",
)
.exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX)
.prefetch_related("employee__departments_links__department")
)
class PayrollComponentCellClearView(PayrollAdminAPIView):

View File

@ -18,7 +18,7 @@ from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from finance.enums import ApprovalStatus
from finance.enums import ApprovalStatus, PayrollComponentKind
from finance.models import (
FinanceSettings,
PayrollCustomComponent,
@ -135,12 +135,77 @@ 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 (
not current.is_custom
and values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE
):
raise ValidationError(
{
"kind": (
"Запланированное изменение создаётся отдельной "
"пользовательской колонкой."
)
}
)
target_references = list(
PayrollCustomComponent.objects.filter(
is_active=True,
scheduled_target_component_code=code,
).select_related("scheduled_application_period")
)
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
)
elif values["period_scope"] == "current_and_future":
target_would_be_unavailable = target_would_be_unavailable or any(
scope_period is None
or reference.scheduled_application_period.date_from
< scope_period.date_from
for reference in target_references
)
if target_references and target_would_be_unavailable:
_conflict(
"COMPONENT_HAS_SCHEDULED_CHANGES",
(
"Колонка выбрана назначением запланированного изменения. "
"Новые настройки должны сохранять её в периоде применения."
),
)
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
)
)
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"])
existing_lines = PayrollInputLine.objects.filter(component_code=code).exclude(
status=ApprovalStatus.VOIDED
)
if scheduled_route_changed and existing_lines.exists():
_conflict(
"COMPONENT_SCHEDULE_HAS_INPUTS",
(
"Нельзя изменить назначение или период маркера, пока в его "
"ячейках есть значения. Сначала очистите значения."
),
)
period_scope = values["period_scope"]
if period_scope == "current_only":
existing_lines = existing_lines.exclude(period=scope_period)
@ -172,9 +237,16 @@ 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"])
custom.save(
update_fields=[
*values,
"scope_period",
"scheduled_application_period",
]
)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
audit_instance = custom
@ -185,6 +257,11 @@ 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
),
}
settings.payroll_component_overrides = overrides
settings.save(update_fields=["payroll_component_overrides", "updated_at"])
@ -260,6 +337,26 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
)
period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id"))
mode = request.data.get("mode")
scheduled_references = PayrollCustomComponent.objects.filter(
is_active=True,
scheduled_target_component_code=code,
)
if mode == "current":
scheduled_references = scheduled_references.filter(
scheduled_application_period=period
)
elif mode == "current_and_future":
scheduled_references = scheduled_references.filter(
scheduled_application_period__date_from__gte=period.date_from
)
if scheduled_references.exists():
_conflict(
"COMPONENT_HAS_SCHEDULED_CHANGES",
(
"Колонка выбрана назначением запланированного изменения. "
"Сначала измените или удалите связанные маркеры."
),
)
affected_lines = PayrollInputLine.objects.filter(component_code=code).exclude(
status=ApprovalStatus.VOIDED
)

View File

@ -7,8 +7,6 @@ by :mod:`finance.payroll.services`.
from __future__ import annotations
from decimal import Decimal
from django.contrib.auth import get_user_model
from rest_framework import serializers
@ -42,7 +40,6 @@ class SetComponentCellCommandSerializer(ClearComponentCellCommandSerializer):
amount = serializers.DecimalField(
max_digits=19,
decimal_places=2,
min_value=Decimal("0.01"),
)

View File

@ -15,6 +15,7 @@ from api.v1.employees.serializers import EmployeeBriefSerializer
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentKind,
PayrollComponentPeriodScope,
)
from finance.models import PayrollCustomComponent, PayrollPeriod
@ -111,6 +112,14 @@ class PayrollComponentSerializer(serializers.Serializer):
visible_to_employee = serializers.BooleanField(read_only=True)
is_custom = serializers.BooleanField(read_only=True)
highlight_color = serializers.CharField(read_only=True, allow_blank=True)
scheduled_target_component_code = serializers.CharField(
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
@ -142,6 +151,12 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
required=False,
write_only=True,
)
scheduled_application_period_id = serializers.PrimaryKeyRelatedField(
source="scheduled_application_period",
queryset=PayrollPeriod.objects.all(),
required=False,
allow_null=True,
)
class Meta:
model = PayrollCustomComponent
@ -155,6 +170,8 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
"highlight_color",
"period_scope",
"period_id",
"scheduled_target_component_code",
"scheduled_application_period_id",
]
def validate(self, attrs):
@ -166,6 +183,60 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"period_id": "Для выбранной области укажите расчётный период."}
)
if attrs["kind"] == PayrollComponentKind.SCHEDULED_CHANGE:
attrs["include_in_point_base"] = False
attrs["include_in_carryover"] = False
attrs["visible_to_employee"] = False
application_period = attrs.get("scheduled_application_period")
source_period = attrs.get("scope_period")
target_code = attrs.get("scheduled_target_component_code", "")
if application_period is None:
raise serializers.ValidationError(
{"scheduled_application_period_id": ("Выберите период применения.")}
)
if (
source_period
and application_period.date_from <= source_period.date_from
):
raise serializers.ValidationError(
{
"scheduled_application_period_id": (
"Период применения должен быть позже начального периода."
)
}
)
if application_period.status == "closed":
raise serializers.ValidationError(
{
"scheduled_application_period_id": (
"Нельзя назначить изменение в закрытый период."
)
}
)
try:
target = get_payroll_component(
target_code,
period=application_period,
)
except ValueError as exc:
raise serializers.ValidationError(
{
"scheduled_target_component_code": (
"Колонка назначения недоступна в периоде применения."
)
}
) from exc
if target.kind == PayrollComponentKind.SCHEDULED_CHANGE:
raise serializers.ValidationError(
{
"scheduled_target_component_code": (
"Запланированное изменение не может изменять другой маркер."
)
}
)
else:
attrs["scheduled_target_component_code"] = ""
attrs["scheduled_application_period"] = None
return attrs

View File

@ -12,6 +12,7 @@ from rest_framework import serializers
from finance.enums import (
InputSource,
PayrollComponentKind,
)
from finance.models import (
PayrollInputLine,
@ -84,6 +85,12 @@ class PayrollInputLineSerializer(serializers.ModelSerializer):
"payroll-carryover:"
)
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.value_sign < 0:
data["amount"] = f"-{data['amount']}"
return data
def get_component(self, obj) -> dict:
return PayrollComponentSerializer(obj.component).data
@ -139,9 +146,17 @@ class PayrollInputLineWriteSerializer(
raise serializers.ValidationError({"component_code": "Обязательное поле."})
period = attrs.get("period") or getattr(self.instance, "period", None)
try:
get_payroll_component(component_code, period=period)
component = get_payroll_component(component_code, period=period)
except ValueError as exc:
raise serializers.ValidationError(
{"component_code": "Колонка недоступна в выбранном периоде."}
) from exc
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE:
raise serializers.ValidationError(
{
"component_code": (
"Запланированное изменение задаётся только в итоговой таблице."
)
}
)
return attrs

View File

@ -29,6 +29,10 @@ class PayrollComponentKind(models.TextChoices):
ADJUSTMENT_DEBIT = "adjustment_debit", "Отрицательная корректировка"
DEDUCTION = "deduction", "Удержать"
PAYMENT = "payment", "Уже выплачено"
SCHEDULED_CHANGE = (
"scheduled_change",
"Изменить выбранную колонку в будущем периоде",
)
class PayrollComponentPeriodScope(models.TextChoices):

View File

@ -0,0 +1,96 @@
# Generated by Django 5.2.4 on 2026-09-11 09:06
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0035_payroll_component_settings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="payrollcustomcomponent",
name="scheduled_application_period",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="scheduled_component_changes",
to="finance.payrollperiod",
verbose_name="Период применения",
),
),
migrations.AddField(
model_name="payrollcustomcomponent",
name="scheduled_target_component_code",
field=models.CharField(
blank=True,
help_text="Заполняется только для запланированного изменения.",
max_length=64,
validators=[
django.core.validators.RegexValidator(
message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-",
regex="^[A-Z][A-Z0-9_.-]{0,63}$",
)
],
verbose_name="Колонка назначения",
),
),
migrations.AddField(
model_name="payrollinputline",
name="value_sign",
field=models.SmallIntegerField(
choices=[(1, "+"), (-1, "−")], default=1, verbose_name="Знак значения"
),
),
migrations.AlterField(
model_name="payrollcustomcomponent",
name="kind",
field=models.CharField(
choices=[
("earning", "Начислить сотруднику"),
("adjustment_credit", "Положительная корректировка"),
("adjustment_debit", "Отрицательная корректировка"),
("deduction", "Удержать"),
("payment", "Уже выплачено"),
(
"scheduled_change",
"Изменить выбранную колонку в будущем периоде",
),
],
max_length=24,
verbose_name="Влияние на расчёт",
),
),
migrations.AlterField(
model_name="payrollstatementline",
name="kind",
field=models.CharField(
choices=[
("earning", "Начислить сотруднику"),
("adjustment_credit", "Положительная корректировка"),
("adjustment_debit", "Отрицательная корректировка"),
("deduction", "Удержать"),
("payment", "Уже выплачено"),
(
"scheduled_change",
"Изменить выбранную колонку в будущем периоде",
),
],
max_length=24,
verbose_name="Тип",
),
),
migrations.AddConstraint(
model_name="payrollinputline",
constraint=models.CheckConstraint(
condition=models.Q(("value_sign__in", (-1, 1))),
name="pay_input_value_sign_valid",
),
),
]

View File

@ -1024,6 +1024,21 @@ class PayrollCustomComponent(models.Model):
),
default="",
)
scheduled_target_component_code = models.CharField(
"Колонка назначения",
max_length=64,
blank=True,
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(
@ -1086,6 +1101,84 @@ class PayrollCustomComponent(models.Model):
{"scope_period": "Для выбранной области укажите расчётный период."}
)
is_scheduled = self.kind == PayrollComponentKind.SCHEDULED_CHANGE
if is_scheduled:
if self.period_scope != PayrollComponentPeriodScope.CURRENT_AND_FUTURE:
raise ValidationError(
{
"period_scope": (
"Запланированное изменение действует от выбранного "
"периода до периода применения."
)
}
)
if not self.scheduled_target_component_code:
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,
)
except ValueError as exc:
raise ValidationError(
{
"scheduled_target_component_code": (
"Колонка назначения недоступна в периоде применения."
)
}
) from exc
if target.kind == PayrollComponentKind.SCHEDULED_CHANGE:
raise ValidationError(
{
"scheduled_target_component_code": (
"Запланированное изменение не может изменять другой маркер."
)
}
)
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
):
raise ValidationError(
{
"scheduled_target_component_code": (
"Колонку назначения можно выбрать только для "
"запланированного изменения."
)
}
)
if not self.is_active:
return
@ -1150,6 +1243,12 @@ 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
@ -1181,6 +1280,11 @@ class PayrollInputLine(DraftVersionedModel):
decimal_places=2,
validators=[MinValueValidator(Decimal("0.01"))],
)
value_sign = models.SmallIntegerField(
"Знак значения",
choices=((1, "+"), (-1, "−")),
default=1,
)
relates_to_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
@ -1278,6 +1382,10 @@ class PayrollInputLine(DraftVersionedModel):
condition=Q(amount__gt=0),
name="pay_input_amount_positive",
),
models.CheckConstraint(
condition=Q(value_sign__in=(-1, 1)),
name="pay_input_value_sign_valid",
),
models.CheckConstraint(
condition=Q(approved_by__isnull=True)
| ~Q(created_by=F("approved_by"))
@ -1305,7 +1413,11 @@ class PayrollInputLine(DraftVersionedModel):
]
def __str__(self):
return f"{self.employee}: {self.component_code} {self.amount}"
return f"{self.employee}: {self.component_code} {self.signed_amount}"
@property
def signed_amount(self):
return self.amount * self.value_sign
@property
def component(self):
@ -1354,6 +1466,25 @@ class PayrollInputLine(DraftVersionedModel):
raise ValidationError(
{"reason": "Для перерасчёта прошлого периода укажите основание."}
)
if self.value_sign not in (-1, 1):
raise ValidationError({"value_sign": "Некорректный знак значения."})
if (
self.value_sign < 0
and component.kind != PayrollComponentKind.SCHEDULED_CHANGE
):
is_derived_value = self.source == InputSource.API and (
self.source_ref.startswith("payroll-scheduled-change:")
or self.source_ref.startswith("payroll-period-copy:")
)
if not is_derived_value:
raise ValidationError(
{
"value_sign": (
"Отрицательный знак доступен только запланированным "
"изменениям."
)
}
)
class VacationPayInstruction(models.Model):

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from collections import defaultdict
from decimal import ROUND_HALF_UP, Context, Decimal, localcontext
from finance.enums import ApprovalStatus, InputSource
from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind
from finance.models import (
EmployeePayRate,
PayrollInputLine,
@ -23,6 +23,7 @@ from payroll_core import (
)
from .config import base_rate_code
from .scheduled_changes import effective_period_lines
POINT_BASE_NEGATIVE_KINDS = frozenset(
{
@ -66,8 +67,53 @@ def _mapped_input_lines(
input_lines: list[PayrollInputLine],
component_definitions,
) -> tuple[InputLine, ...]:
relevant_lines = [
record
for record in input_lines
if component_definitions[record.component_code].kind
!= PayrollComponentKind.SCHEDULED_CHANGE
]
signed_codes = {
record.component_code for record in relevant_lines if record.value_sign < 0
}
mapped_lines = []
for record in input_lines:
for component_code in signed_codes:
records = [
record
for record in relevant_lines
if record.component_code == component_code
]
component = component_definitions[component_code]
amount = sum((record.signed_amount for record in records), Decimal("0"))
if amount < 0:
raise ValueError(
f"Итоговое значение колонки {component.name} не может быть отрицательным."
)
if amount == 0:
continue
mapped_lines.append(
InputLine(
line_id=(
f"effective:{component_code}:"
+ ",".join(str(record.pk) for record in records)
),
code=component.code,
label=component.name,
kind=LineKind(component.kind),
amount=amount,
source_ref="scheduled-effective-value",
reason="; ".join(
dict.fromkeys(record.reason for record in records if record.reason)
),
include_in_carryover=(
component.include_in_carryover
and any(record.include_in_carryover for record in records)
),
)
)
for record in relevant_lines:
if record.component_code in signed_codes:
continue
component = component_definitions[record.component_code]
source_period = None
if record.relates_to_period_id:
@ -132,7 +178,9 @@ def _point_base_accrual(
if not component.include_in_point_base:
continue
direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1
point_base_lines += record.amount * direction
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE:
continue
point_base_lines += record.signed_amount * direction
return base_amount + point_base_lines
@ -205,6 +253,16 @@ def build_core_request(
component_definitions=None,
) -> PayrollRequest:
components_by_code = _component_map(period, component_definitions)
input_lines = effective_period_lines(
period,
rate.employee,
input_lines,
scheduled_codes={
code
for code, component in components_by_code.items()
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE
},
)
target_points = work_record.target_points if work_record else None
point_base_accrual = _point_base_accrual(
rate,

View File

@ -19,6 +19,8 @@ class PayrollComponentDefinition:
scope_period_id: int | None = None
is_custom: bool = False
highlight_color: str = ""
scheduled_target_component_code: str = ""
scheduled_application_period_id: int | None = None
@property
def id(self) -> str:
@ -189,12 +191,24 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
scope_period_id=override.get(
"scope_period_id", component.scope_period_id
),
scheduled_target_component_code=override.get(
"scheduled_target_component_code",
component.scheduled_target_component_code,
),
scheduled_application_period_id=override.get(
"scheduled_application_period_id",
component.scheduled_application_period_id,
),
)
)
custom = list(
PayrollCustomComponent.objects.filter(is_active=True)
.select_related("scope_period", "hidden_from_period")
.select_related(
"scope_period",
"hidden_from_period",
"scheduled_application_period",
)
.prefetch_related("excluded_periods")
.order_by("display_order", "name", "id")
)

View File

@ -40,6 +40,7 @@ from .attendance import (
)
from .balances import opening_balances
from .config import base_rate_code, build_rules
from .scheduled_changes import applied_change_payloads, effective_period_lines
from .work_norm import (
calculate_period_personnel_daily_points,
calculate_period_personnel_points,
@ -189,6 +190,8 @@ def _component_definition(
scope_period_id=None,
is_custom=False,
highlight_color="",
scheduled_target_component_code="",
scheduled_application_period_id=None,
):
return {
"code": code,
@ -203,6 +206,8 @@ def _component_definition(
"scope_period_id": scope_period_id,
"is_custom": is_custom,
"highlight_color": highlight_color,
"scheduled_target_component_code": scheduled_target_component_code,
"scheduled_application_period_id": scheduled_application_period_id,
}
@ -230,7 +235,7 @@ def _source_component_amounts(records, component_definitions, component_order):
amounts = defaultdict(lambda: Decimal("0"))
for record in records:
component = record.component
amounts[component.code] += record.amount
amounts[component.code] += record.signed_amount
component_order.setdefault(
component.code,
_component_definition(
@ -408,6 +413,8 @@ def build_payroll_period_table(period: PayrollPeriod):
scope_period_id=component.scope_period_id,
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
}
@ -416,6 +423,11 @@ def build_payroll_period_table(period: PayrollPeriod):
for component in components
if component.code not in SYSTEM_COMPONENT_CODES
}
scheduled_component_codes = {
code
for code, definition in component_definitions.items()
if definition["kind"] == "scheduled_change"
}
calculator = DeterministicPayrollCalculator()
active_rules = build_rules()
preview_rules = replace(active_rules, allow_negative_payable=True)
@ -427,7 +439,21 @@ def build_payroll_period_table(period: PayrollPeriod):
statement = statements.get(employee.pk)
rate = rates.get(employee.pk)
work = work_records.get(employee.pk)
source_lines = input_lines.get(employee.pk, [])
raw_source_lines = input_lines.get(employee.pk, [])
source_lines = effective_period_lines(
period,
employee,
raw_source_lines,
scheduled_codes=scheduled_component_codes,
)
display_source_lines = [
line
for line in raw_source_lines
if line in source_lines
or component_definitions.get(line.component_code, {}).get("kind")
== "scheduled_change"
]
applied_scheduled_changes = applied_change_payloads(source_lines)
schedule, target_points_source = resolve_employee_schedule(employee)
automatic_target_points, scheduled_workdays, _ = calculate_period_target_points(
period,
@ -444,6 +470,20 @@ def build_payroll_period_table(period: PayrollPeriod):
component_definitions,
component_order,
)
component_amounts.update(
_source_component_amounts(
[
line
for line in display_source_lines
if component_definitions.get(line.component_code, {}).get(
"kind"
)
== "scheduled_change"
],
component_definitions,
component_order,
)
)
status = "calculated"
totals = {
"gross_before_adjustments": str(statement.gross_before_adjustments),
@ -484,7 +524,7 @@ def build_payroll_period_table(period: PayrollPeriod):
)
else:
component_amounts = _source_component_amounts(
source_lines,
display_source_lines,
component_definitions,
component_order,
)
@ -629,6 +669,7 @@ def build_payroll_period_table(period: PayrollPeriod):
"closing_balance": str(closing_balance),
"payment_confirmation": payment_confirmation,
"calculation_issue": calculation_issue,
"applied_scheduled_changes": applied_scheduled_changes,
**totals,
}
)

View File

@ -0,0 +1,144 @@
"""Helpers for signed, one-period payroll component changes."""
from __future__ import annotations
from decimal import Decimal
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.models import PayrollCustomComponent, PayrollInputLine, PayrollPeriod
from requests_app.enums import RequestStatus
SCHEDULED_CHANGE_SOURCE_PREFIX = "payroll-scheduled-change:"
def is_generated_scheduled_change(line: PayrollInputLine) -> bool:
return line.source_ref.startswith(SCHEDULED_CHANGE_SOURCE_PREFIX)
def generated_source_line_id(line: PayrollInputLine) -> int | None:
if not is_generated_scheduled_change(line):
return None
raw_id = line.source_ref.removeprefix(SCHEDULED_CHANGE_SOURCE_PREFIX)
try:
return int(raw_id)
except (TypeError, ValueError):
return None
def scheduled_component_for_period(code: str, period: PayrollPeriod):
component = (
PayrollCustomComponent.objects.select_related(
"scope_period",
"hidden_from_period",
"scheduled_application_period",
)
.prefetch_related("excluded_periods")
.filter(
code=code,
is_active=True,
kind=PayrollComponentKind.SCHEDULED_CHANGE,
)
.first()
)
if component is None or not component.applies_to_period(period):
return None
return component
def employee_is_eligible_for_scheduled_change(employee, period: PayrollPeriod) -> bool:
if not employee.is_active:
return False
actions = getattr(employee, "payroll_personnel_actions", None)
if actions is None:
actions = list(
employee.actions.filter(
Q(source_request__isnull=True)
| Q(source_request__status=RequestStatus.APPROVED)
).select_related("source_request")
)
state = resolve_employee_personnel_state(employee, period.date_to, actions=actions)
return state.status != ACTION_DISMISSED
def effective_period_lines(
period: PayrollPeriod,
employee,
lines: list[PayrollInputLine],
*,
scheduled_codes: set[str] | None = None,
) -> list[PayrollInputLine]:
"""Remove marker inputs and inapplicable generated effects from calculation."""
eligible = employee_is_eligible_for_scheduled_change(employee, period)
if scheduled_codes is None:
scheduled_codes = set(
PayrollCustomComponent.objects.filter(
kind=PayrollComponentKind.SCHEDULED_CHANGE,
).values_list("code", flat=True)
)
return [
line
for line in lines
if line.component_code not in scheduled_codes
and (eligible or not is_generated_scheduled_change(line))
]
def target_cell_scheduled_delta(
*,
period: PayrollPeriod,
employee_id: int,
component_code: str,
) -> Decimal:
lines = PayrollInputLine.objects.filter(
period=period,
employee_id=employee_id,
component_code=component_code,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX,
)
return sum((line.signed_amount for line in lines), Decimal("0"))
def applied_change_payloads(lines: list[PayrollInputLine]) -> list[dict[str, object]]:
generated = [line for line in lines if is_generated_scheduled_change(line)]
source_ids = {
source_id
for line in generated
if (source_id := generated_source_line_id(line)) is not None
}
sources = {
line.pk: line
for line in PayrollInputLine.objects.filter(pk__in=source_ids).select_related(
"period"
)
}
components = {
component.code: component
for component in PayrollCustomComponent.objects.filter(
code__in={line.component_code for line in sources.values()}
)
}
payloads = []
for generated_line in generated:
source = sources.get(generated_source_line_id(generated_line))
if source is None:
continue
component = components.get(source.component_code)
if component is None:
continue
payloads.append(
{
"target_component_code": generated_line.component_code,
"amount": str(generated_line.signed_amount),
"source_period_id": source.period_id,
"source_period_code": source.period.code,
"source_component_code": source.component_code,
"source_component_name": component.name,
}
)
return payloads

View File

@ -60,6 +60,13 @@ from .config import (
ruleset_period_details,
)
from .exceptions import PayrollOperationError, PayrollPermissionDenied
from .scheduled_changes import (
SCHEDULED_CHANGE_SOURCE_PREFIX,
employee_is_eligible_for_scheduled_change,
is_generated_scheduled_change,
scheduled_component_for_period,
target_cell_scheduled_delta,
)
from .work_norm import calculate_period_target_points
Employee = get_user_model()
@ -138,6 +145,22 @@ def _mark_linked_vacation_instruction_posted(line: PayrollInputLine) -> None:
terms.save(update_fields=["finance_status", "updated_at"])
def _sync_generated_scheduled_change_approval(line: PayrollInputLine) -> None:
component = scheduled_component_for_period(line.component_code, line.period)
if component is None:
return
PayrollInputLine.objects.filter(
source=InputSource.API,
source_ref=f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}",
status=ApprovalStatus.DRAFT,
).update(
status=ApprovalStatus.APPROVED,
approved_by=line.approved_by,
approved_at=line.approved_at,
self_approval_overridden=line.self_approval_overridden,
)
def _resolve_rates(
period: PayrollPeriod,
employee_ids: list[int],
@ -1952,6 +1975,7 @@ def clear_component_input_lines(
employee_id: int,
component_code: str,
actor,
validate_scheduled_result: bool = True,
) -> dict[str, int]:
"""Clear one spreadsheet-like component cell without losing its audit trail."""
@ -1960,8 +1984,40 @@ def clear_component_input_lines(
if period is None:
_operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.")
_ensure_period_accepts_approvals(period)
if not Employee.objects.filter(pk=employee_id).exists():
employee = Employee.objects.filter(pk=employee_id).first()
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
):
scheduled_delta = target_cell_scheduled_delta(
period=period,
employee_id=employee_id,
component_code=component_code,
)
if scheduled_delta < 0:
_operation_error(
"NEGATIVE_SCHEDULED_TARGET",
(
"Нельзя очистить обычное значение: применённый маркер "
"сделает итог колонки отрицательным."
),
scheduled_delta=str(scheduled_delta),
)
lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
@ -1972,6 +2028,21 @@ def clear_component_input_lines(
)
.order_by("pk")
)
if scheduled_component is None:
lines = [line for line in lines if not is_generated_scheduled_change(line)]
generated_lines = []
if scheduled_component is not None and lines:
generated_refs = [
f"{SCHEDULED_CHANGE_SOURCE_PREFIX}{line.pk}" for line in lines
]
generated_lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
source_ref__in=generated_refs,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
now = timezone.now()
summary = {"draft": 0, "approved": 0, "total": len(lines)}
for line in lines:
@ -1996,6 +2067,14 @@ def clear_component_input_lines(
period=period,
metadata={"previous_status": previous_status},
)
for generated in generated_lines:
generated.status = ApprovalStatus.VOIDED
generated.voided_by = actor
generated.voided_at = now
generated.void_reason = "Исходное запланированное изменение очищено"
generated.save(
update_fields=["status", "voided_by", "voided_at", "void_reason"]
)
return summary
@ -2019,17 +2098,47 @@ def set_component_input_line_value(
"Колонка недоступна в выбранном расчётном периоде.",
details={"component_code": component_code},
) from exc
scheduled_component = scheduled_component_for_period(component_code, period)
signed_amount = Decimal(amount)
if signed_amount == 0:
_operation_error(
"INPUT_VALIDATION_FAILED",
"Значение ячейки не может быть нулевым.",
)
stored_amount = signed_amount
if scheduled_component is None:
employee = Employee.objects.filter(pk=employee_id).first()
if employee is None:
_operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.")
if employee_is_eligible_for_scheduled_change(employee, period):
stored_amount -= target_cell_scheduled_delta(
period=period,
employee_id=employee_id,
component_code=component_code,
)
if stored_amount <= 0:
_operation_error(
"INVALID_COMPONENT_BASE",
(
"Итоговое значение должно быть больше уже применённого "
"запланированного изменения."
),
desired_amount=str(signed_amount),
scheduled_delta=str(signed_amount - stored_amount),
)
clear_component_input_lines(
period_id,
employee_id=employee_id,
component_code=component_code,
actor=actor,
validate_scheduled_result=False,
)
line = PayrollInputLine(
period=period,
employee_id=employee_id,
component_code=component_code,
amount=amount,
amount=abs(stored_amount),
value_sign=-1 if stored_amount < 0 else 1,
reason="Перезаписано в итоговой таблице",
status=ApprovalStatus.DRAFT,
source=InputSource.MANUAL,
@ -2048,6 +2157,87 @@ 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,
)
try:
generated.full_clean()
generated.save(force_insert=True)
except ValidationError as exc:
raise PayrollOperationError(
"INPUT_VALIDATION_FAILED",
"Запланированное изменение не прошло проверку.",
details={
"errors": (
exc.message_dict
if hasattr(exc, "message_dict")
else exc.messages
)
},
) from exc
target_lines = PayrollInputLine.objects.filter(
period=target_period,
employee_id=employee_id,
component_code=generated.component_code,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
base_amount = sum(
(
target_line.signed_amount
for target_line in target_lines
if not is_generated_scheduled_change(target_line)
),
Decimal("0"),
)
scheduled_delta = sum(
(
target_line.signed_amount
for target_line in target_lines
if is_generated_scheduled_change(target_line)
),
Decimal("0"),
)
result_amount = base_amount + scheduled_delta
if result_amount < 0:
_operation_error(
"NEGATIVE_SCHEDULED_TARGET",
"Запланированное изменение делает значение колонки отрицательным.",
application_period_id=target_period.pk,
target_component_code=generated.component_code,
current_amount=str(base_amount),
scheduled_delta=str(scheduled_delta),
result_amount=str(result_amount),
)
target_point_base = point_base_accruals_for_employees(
period=target_period,
employee_ids=[employee_id],
)[employee_id]
if target_point_base < 0:
_operation_error(
"NEGATIVE_POINT_BASE",
"Изменение создаёт отрицательную балльную базу в периоде применения.",
employee_id=employee_id,
component_code=generated.component_code,
application_period_id=target_period.pk,
point_base=str(target_point_base),
)
point_base_accrual = point_base_accruals_for_employees(
period=period,
employee_ids=[employee_id],
@ -2068,7 +2258,7 @@ def set_component_input_line_value(
action="payroll.input_line_draft_created_from_table",
instance=line,
period=period,
metadata={"component_code": component_code, "amount": str(amount)},
metadata={"component_code": component_code, "amount": str(signed_amount)},
)
return line
@ -2118,6 +2308,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]:
period=period,
status=ApprovalStatus.DRAFT,
)
.exclude(source_ref__startswith=SCHEDULED_CHANGE_SOURCE_PREFIX)
.order_by("pk")
.values_list("pk", "component_code")
)
@ -2152,6 +2343,24 @@ 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
)
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,
)
for rate in rates:
_approve_record(
rate,
@ -2174,6 +2383,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]:
expected_lock_version=line.lock_version,
)
_mark_linked_vacation_instruction_posted(approved_line)
_sync_generated_scheduled_change_approval(approved_line)
summary = {
"rates": len(rates),
@ -2275,6 +2485,25 @@ def approve_input_line(
period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id)
_ensure_period_accepts_approvals(period)
record = PayrollInputLine.objects.select_for_update().get(pk=line_id)
if is_generated_scheduled_change(record):
_operation_error(
"SCHEDULED_CHANGE_DERIVED",
"Автоматическая строка утверждается вместе с исходным маркером.",
)
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,
)
approved_line = _approve_record(
record,
actor=actor,
@ -2282,6 +2511,7 @@ def approve_input_line(
expected_lock_version=expected_lock_version,
)
_mark_linked_vacation_instruction_posted(approved_line)
_sync_generated_scheduled_change_approval(approved_line)
return approved_line

View File

@ -213,6 +213,8 @@ def test_manager_can_customize_system_component_behavior(
"visible_to_employee": False,
"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

File diff suppressed because it is too large Load Diff

View File

@ -1645,7 +1645,9 @@ export function PayrollInputLineFormModal({
>
<option value="">Выберите вид</option>
{components
.filter((item) => item.is_active)
.filter(
(item) => item.is_active && item.kind !== "scheduled_change",
)
.map((item) => (
<option value={item.code} key={item.code}>
{item.name}

View File

@ -753,7 +753,9 @@ export function PayrollInputLinesTable({
<RecordActions
status={record.status}
isAuthor={record.created_by.id === currentUserId}
canManage={canManage}
canManage={
canManage && record.component.kind !== "scheduled_change"
}
canApprove={canApprove}
canOverrideApproval={canOverrideApproval}
fullAccess={fullAccess}
@ -834,7 +836,9 @@ export function PayrollInputLinesTable({
<RecordActions
status={record.status}
isAuthor={record.created_by.id === currentUserId}
canManage={canManage}
canManage={
canManage && record.component.kind !== "scheduled_change"
}
canApprove={canApprove}
canOverrideApproval={canOverrideApproval}
fullAccess={fullAccess}

View File

@ -751,17 +751,24 @@ export function PayrollAdminWorkspace({
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
const storedValue = window.localStorage.getItem(
`${PAYROLL_SELECTED_PERIOD_STORAGE_PREFIX}.${user.id}`,
);
const storedPeriodId = storedValue ? Number(storedValue) : undefined;
void loadWorkspace(
storedPeriodId && Number.isInteger(storedPeriodId) && storedPeriodId > 0
? storedPeriodId
: undefined,
false,
true,
);
queueMicrotask(() => {
if (cancelled) return;
void loadWorkspace(
storedPeriodId && Number.isInteger(storedPeriodId) && storedPeriodId > 0
? storedPeriodId
: undefined,
false,
true,
);
});
return () => {
cancelled = true;
};
}, [loadWorkspace, user?.id]);
useEffect(() => {
@ -773,12 +780,24 @@ export function PayrollAdminWorkspace({
}, [selectedPeriod?.id, user?.id]);
useEffect(() => {
let cancelled = false;
const defer = (callback: () => void) => {
queueMicrotask(() => {
if (!cancelled) callback();
});
};
const cancel = () => {
cancelled = true;
};
if (!selectedPeriod) {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setPeriodTable(null);
return;
defer(() => {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setPeriodTable(null);
});
return cancel;
}
if (tab === "readiness") return;
const canLoadInputs = Boolean(
@ -787,33 +806,38 @@ export function PayrollAdminWorkspace({
workspace?.permissions.view_all,
);
if (["rates", "work", "inputs"].includes(tab) && !canLoadInputs) {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setTableError(null);
setTableLoading(false);
return;
defer(() => {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setTableError(null);
setTableLoading(false);
});
return cancel;
}
if (tab === "approval" && !canLoadInputs) {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setTableError(null);
setTableLoading(false);
return;
defer(() => {
setRates([]);
setWorkRecords([]);
setInputLines([]);
setTableError(null);
setTableLoading(false);
});
return cancel;
}
if (tab === "summary" && !workspace?.permissions.view_all) {
setPeriodTable(null);
setTableError(null);
setTableLoading(false);
return;
defer(() => {
setPeriodTable(null);
setTableError(null);
setTableLoading(false);
});
return cancel;
}
let cancelled = false;
setTableLoading(true);
setTableError(null);
const params = { period_id: selectedPeriod.id };
const load = async () => {
setTableLoading(true);
setTableError(null);
try {
if (tab === "summary") {
setPeriodTable(null);
@ -863,10 +887,8 @@ export function PayrollAdminWorkspace({
if (!cancelled) setTableLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
defer(() => void load());
return cancel;
}, [
selectedPeriod,
tab,
@ -1557,9 +1579,16 @@ export function PayrollAdminWorkspace({
return;
}
const desiredAmount = Number(normalized);
if (!Number.isFinite(desiredAmount) || desiredAmount <= 0) {
const isScheduledChange = component.kind === "scheduled_change";
if (
!Number.isFinite(desiredAmount) ||
desiredAmount === 0 ||
(!isScheduledChange && desiredAmount < 0)
) {
throw new Error(
"Сумма операции должна быть больше нуля или оставлена пустой.",
isScheduledChange
? "Маркер должен быть положительным или отрицательным, но не нулевым."
: "Сумма операции должна быть больше нуля или оставлена пустой.",
);
}
await apiClient.setPayrollAdminComponentCell(selectedPeriod.id, {
@ -2198,6 +2227,7 @@ export function PayrollAdminWorkspace({
<PayrollPeriodTableView
compact
data={periodTable}
periods={workspace.periods}
loading={tableLoading}
error={tableError}
search={search}

View File

@ -51,6 +51,7 @@ import {
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollAdminPeriod,
PayrollComponentPeriodScope,
PayrollColumnHighlightColor,
PayrollCustomComponentWrite,
@ -88,6 +89,7 @@ const kindLabels = {
adjustment_debit: "Отрицательная корректировка",
deduction: "Удержать",
payment: "Уже выплачено",
scheduled_change: "Изменить колонку в будущем периоде",
};
const kindHelpItems: Array<{
@ -126,6 +128,13 @@ const kindHelpItems: Array<{
formula:
"Уменьшает только остаток «К выплате», не меняя начисления и удержания.",
},
{
kind: "scheduled_change",
description:
"Внутренний маркер: сейчас ничего не меняет, а в выбранном будущем периоде один раз изменяет выбранную колонку.",
formula:
"Цель в периоде применения = обычное значение цели + сумма всех маркеров.",
},
];
const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
@ -714,6 +723,8 @@ function PayrollComponentSettingsModal({
component,
rows,
components,
periods,
currentPeriodId,
currency,
onSave,
onDelete,
@ -722,6 +733,8 @@ 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>;
@ -748,6 +761,15 @@ function PayrollComponentSettingsModal({
const [periodScope, setPeriodScope] = useState<PayrollComponentPeriodScope>(
component?.period_scope || "current_and_future",
);
const [scheduledTargetCode, setScheduledTargetCode] = useState(
component?.scheduled_target_component_code || "",
);
const [scheduledApplicationPeriodId, setScheduledApplicationPeriodId] =
useState(
component?.scheduled_application_period_id
? String(component.scheduled_application_period_id)
: "",
);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
@ -776,6 +798,21 @@ function PayrollComponentSettingsModal({
};
}, [onClose, pending]);
const currentPeriod = periods.find((item) => item.id === currentPeriodId);
const futurePeriods = periods.filter(
(item) =>
item.status !== "closed" &&
Boolean(
currentPeriod &&
new Date(item.date_from).getTime() >
new Date(currentPeriod.date_from).getTime(),
),
);
const scheduledTargets = components.filter(
(item) => item.code !== component?.code && item.kind !== "scheduled_change",
);
const isScheduled = kind === "scheduled_change";
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedName = name.trim();
@ -783,7 +820,15 @@ function PayrollComponentSettingsModal({
setError("Укажите название колонки.");
return;
}
if (pointBaseAssessment.negative.length) {
if (isScheduled && !scheduledTargetCode) {
setError("Выберите колонку, значение которой нужно изменить.");
return;
}
if (isScheduled && !scheduledApplicationPeriodId) {
setError("Выберите будущий период применения.");
return;
}
if (!isScheduled && pointBaseAssessment.negative.length) {
setError(
"Сохранение заблокировано: настройка создаёт отрицательную балльную базу.",
);
@ -796,11 +841,15 @@ function PayrollComponentSettingsModal({
name: normalizedName,
kind,
requires_reason: requiresReason,
include_in_point_base: includeInPointBase,
include_in_carryover: includeInCarryover,
visible_to_employee: visibleToEmployee,
include_in_point_base: isScheduled ? false : includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
highlight_color: highlightColor,
period_scope: periodScope,
period_scope: isScheduled ? "current_and_future" : periodScope,
scheduled_target_component_code: isScheduled ? scheduledTargetCode : "",
scheduled_application_period_id: isScheduled
? Number(scheduledApplicationPeriodId)
: null,
});
onClose();
} catch (createError) {
@ -839,15 +888,24 @@ function PayrollComponentSettingsModal({
kind,
display_order: component?.display_order || 0,
requires_reason: requiresReason,
include_in_point_base: includeInPointBase,
include_in_carryover: includeInCarryover,
visible_to_employee: visibleToEmployee,
period_scope: periodScope,
include_in_point_base: isScheduled ? false : includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
period_scope: isScheduled ? "current_and_future" : periodScope,
scope_period_id: component?.scope_period_id ?? null,
is_custom: component?.is_custom ?? true,
highlight_color: highlightColor,
scheduled_target_component_code: isScheduled ? scheduledTargetCode : "",
scheduled_application_period_id:
isScheduled && scheduledApplicationPeriodId
? Number(scheduledApplicationPeriodId)
: null,
};
const calculationPreview = componentCalculationHelp(previewComponent);
const calculationPreview = componentCalculationHelp(
previewComponent,
components,
periods,
);
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
code: component?.code || "PREVIEW",
kind,
@ -932,13 +990,72 @@ function PayrollComponentSettingsModal({
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending}
>
{Object.entries(kindLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
{Object.entries(kindLabels)
.filter(
([value]) =>
value !== "scheduled_change" ||
component?.is_custom !== false,
)
.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{isScheduled ? (
<div className="app-surface-muted space-y-3 rounded-xl p-3">
<label className="block">
<span className="app-field-label">Какую колонку изменить</span>
<select
value={scheduledTargetCode}
onChange={(event) =>
setScheduledTargetCode(event.target.value)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
required
disabled={pending}
>
<option value="">Выберите колонку</option>
{scheduledTargets.map((item) => (
<option key={item.code} value={item.code}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-field-label">Когда применить</span>
<select
value={scheduledApplicationPeriodId}
onChange={(event) =>
setScheduledApplicationPeriodId(event.target.value)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
required
disabled={pending}
>
<option value="">Выберите будущий период</option>
{futurePeriods.map((item) => (
<option key={item.id} value={item.id}>
{item.name || item.code}
</option>
))}
</select>
{!futurePeriods.length ? (
<span className="app-text-muted mt-1.5 block text-xs">
Нет открытых будущих периодов. Сначала создайте следующий
расчётный период.
</span>
) : null}
</label>
<p className="app-text-muted text-xs leading-relaxed">
Маркер допускает плюс и минус, не участвует в текущем расчёте и
исчезает после применения. В целевой колонке останется отметка
об источнике изменения.
</p>
</div>
) : null}
<section
className="app-selected rounded-xl border border-[var(--border-subtle)] px-3 py-3"
aria-live="polite"
@ -988,14 +1105,14 @@ function PayrollComponentSettingsModal({
<label className="block">
<span className="app-field-label">В каких периодах показывать</span>
<select
value={periodScope}
value={isScheduled ? "current_and_future" : periodScope}
onChange={(event) =>
setPeriodScope(
event.target.value as PayrollComponentPeriodScope,
)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending}
disabled={pending || isScheduled}
>
{Object.entries(periodScopeLabels).map(([value, label]) => (
<option key={value} value={value}>
@ -1028,10 +1145,10 @@ function PayrollComponentSettingsModal({
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={includeInCarryover}
checked={isScheduled ? false : includeInCarryover}
onChange={(event) => setIncludeInCarryover(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
@ -1046,10 +1163,10 @@ function PayrollComponentSettingsModal({
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={includeInPointBase}
checked={isScheduled ? false : includeInPointBase}
onChange={(event) => setIncludeInPointBase(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
@ -1063,10 +1180,10 @@ function PayrollComponentSettingsModal({
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={visibleToEmployee}
checked={isScheduled ? false : visibleToEmployee}
onChange={(event) => setVisibleToEmployee(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
@ -1077,7 +1194,7 @@ function PayrollComponentSettingsModal({
</span>
</span>
</label>
{pointBaseAssessment.negative.length ? (
{!isScheduled && pointBaseAssessment.negative.length ? (
<div
className="app-feedback-danger rounded-xl px-3 py-3 text-sm"
role="alert"
@ -1099,7 +1216,7 @@ function PayrollComponentSettingsModal({
))}
</ul>
</div>
) : pointBaseAssessment.zero.length ? (
) : !isScheduled && pointBaseAssessment.zero.length ? (
<div
className="app-feedback-warning rounded-xl px-3 py-3 text-sm"
role="status"
@ -1131,7 +1248,10 @@ function PayrollComponentSettingsModal({
<button
type="submit"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
disabled={pending || pointBaseAssessment.negative.length > 0}
disabled={
pending ||
(!isScheduled && pointBaseAssessment.negative.length > 0)
}
>
{pending ? (
<Loader2 className="animate-spin" size={15} />
@ -1435,7 +1555,27 @@ 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 || "выбранный период";
return {
title: component.label,
logic: `Это внутренний маркер. В текущем периоде он не меняет расчёт и не показывается сотруднику. В периоде «${periodLabel}» сумма один раз прибавится к колонке «${targetLabel}»; отрицательная сумма уменьшит её. Несколько маркеров складываются.`,
formula: `Текущий период: ${component.label} → без влияния\n\n${periodLabel}:\n${targetLabel} = обычное значение ${targetLabel} + Σ «${component.label}»\n\nПри штатном переносе в следующие периоды переносится уже полученное значение ${targetLabel}, сам маркер не переносится`,
manual:
"Да. В ячейке маркера задаётся итоговая знаковая сумма для сотрудника. После применения в целевой ячейке отображается отметка с источником изменения.",
};
}
const flow = {
earning: {
effect:
@ -1462,6 +1602,10 @@ function componentCalculationHelp(
"увеличивает «Выплачено» и тем самым уменьшает остаток «К выплате»; на начисленную зарплату не влияет",
formula: `${component.label} → Выплачено (+) → К выплате (−)`,
},
scheduled_change: {
effect: "не влияет на текущий период",
formula: `${component.label} → без влияния`,
},
}[component.kind];
const decreasesCarryover = ["earning", "adjustment_credit"].includes(
component.kind,
@ -1713,6 +1857,7 @@ function InlineEditableCell({
commentCount,
onOpenComment,
cellClassName = "",
annotation,
}: {
value: string | null;
formattedValue: string;
@ -1722,6 +1867,7 @@ function InlineEditableCell({
commentCount: number;
onOpenComment: () => void;
cellClassName?: string;
annotation?: { label: string; title: string };
}) {
const original = editableNumber(value);
const [draft, setDraft] = useState(original);
@ -1729,10 +1875,6 @@ function InlineEditableCell({
const [cellError, setCellError] = useState<string | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
if (!pending) setDraft(editableNumber(value));
}, [pending, value]);
if (!onSave)
return (
<CommentableValueCell
@ -1741,7 +1883,15 @@ function InlineEditableCell({
commentLabel={label}
onOpenComment={onOpenComment}
>
{formattedValue}
<span>{formattedValue}</span>
{annotation ? (
<span
className="app-accent-text ml-1 inline-block text-[9px] font-semibold"
title={annotation.title}
>
{annotation.label}
</span>
) : null}
</CommentableValueCell>
);
@ -1795,6 +1945,14 @@ function InlineEditableCell({
}
}}
/>
{annotation ? (
<span
className="app-accent-text pointer-events-none absolute bottom-0.5 left-1 text-[8px] font-semibold"
title={annotation.title}
>
{annotation.label}
</span>
) : null}
{pending ? (
<Loader2
className="app-accent-text pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 animate-spin"
@ -1830,6 +1988,7 @@ type CompactPeriodPanel = "summary" | "table" | "help";
export function PayrollPeriodTableView({
data,
periods = [],
loading,
error,
search,
@ -1852,6 +2011,7 @@ export function PayrollPeriodTableView({
compact = false,
}: {
data: PayrollPeriodTable | null;
periods?: PayrollAdminPeriod[];
loading: boolean;
error: string | null;
search: string;
@ -2271,7 +2431,11 @@ export function PayrollPeriodTableView({
>
<ColumnHeader
label={component.label}
help={componentCalculationHelp(component)}
help={componentCalculationHelp(
component,
data.component_columns,
periods,
)}
onConfigure={
onUpdateComponent
? () => {
@ -2477,8 +2641,27 @@ export function PayrollPeriodTableView({
const component = componentsByColumnId.get(column.id);
const cellVisualClass = columnVisualClass(column, "body");
if (component) {
const scheduledChanges = (row.applied_scheduled_changes || []).filter(
(item) => item.target_component_code === component.code,
);
const scheduledDelta = scheduledChanges.reduce(
(sum, item) => sum + Number(item.amount),
0,
);
const annotation = scheduledChanges.length
? {
label: `изм. ${scheduledDelta > 0 ? "+" : ""}${scheduledDelta}`,
title: scheduledChanges
.map(
(item) =>
`${item.source_component_name} (${item.source_period_code}): ${Number(item.amount) > 0 ? "+" : ""}${item.amount}`,
)
.join("\n"),
}
: undefined;
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.component_amounts[component.code] || ""}`}
cellClassName={cellVisualClass}
value={row.component_amounts[component.code] || null}
formattedValue={formatMoney(
@ -2491,6 +2674,7 @@ export function PayrollPeriodTableView({
: undefined
}
label={`${component.label}: ${row.employee.display_name}`}
annotation={annotation}
{...comments}
/>
);
@ -2499,6 +2683,7 @@ export function PayrollPeriodTableView({
case columnIds.rateAmount:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.rate_amount || ""}`}
cellClassName={cellVisualClass}
value={row.rate_amount}
formattedValue={formatMoney(row.rate_amount, data.currency)}
@ -2514,6 +2699,7 @@ export function PayrollPeriodTableView({
case columnIds.inNormPointRate:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.in_norm_point_rate || ""}`}
cellClassName={cellVisualClass}
value={row.in_norm_point_rate}
formattedValue={formatMoney(row.in_norm_point_rate, data.currency)}
@ -2525,6 +2711,7 @@ export function PayrollPeriodTableView({
case columnIds.excessPointRate:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.point_rate ?? row.in_norm_point_rate ?? ""}`}
cellClassName={cellVisualClass}
value={row.point_rate ?? row.in_norm_point_rate}
formattedValue={formatMoney(
@ -2544,6 +2731,7 @@ export function PayrollPeriodTableView({
case columnIds.targetPoints:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.target_points || ""}`}
cellClassName={cellVisualClass}
value={row.target_points}
formattedValue={formatPoints(row.target_points)}
@ -2592,6 +2780,7 @@ export function PayrollPeriodTableView({
case columnIds.actualPoints:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.actual_points || ""}`}
cellClassName={cellVisualClass}
value={row.actual_points}
formattedValue={formatPoints(row.actual_points)}
@ -3666,6 +3855,8 @@ 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

@ -5,7 +5,8 @@ export type PayrollLineKind =
| "adjustment_credit"
| "adjustment_debit"
| "deduction"
| "payment";
| "payment"
| "scheduled_change";
export type PayrollComponentPeriodScope =
| "current_only"
@ -400,6 +401,8 @@ export type PayrollPeriodTableComponent = {
scope_period_id: number | null;
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
scheduled_target_component_code: string;
scheduled_application_period_id: number | null;
};
export type PayrollCellCommentAuthor = {
@ -459,6 +462,14 @@ export type PayrollPeriodTableRow = {
point_delta: string | null;
point_amount: string | null;
component_amounts: Record<string, string>;
applied_scheduled_changes: Array<{
target_component_code: string;
amount: string;
source_period_id: number;
source_period_code: string;
source_component_code: string;
source_component_name: string;
}>;
totals_preliminary: boolean;
gross_before_adjustments: string | null;
adjustment_total: string | null;
@ -625,6 +636,8 @@ export type PayrollComponent = {
visible_to_employee: boolean;
is_custom: boolean;
highlight_color: PayrollColumnHighlightColor;
scheduled_target_component_code: string;
scheduled_application_period_id: number | null;
};
export type PayrollColumnHighlightColor =
@ -644,6 +657,8 @@ export type PayrollCustomComponentWrite = {
visible_to_employee: boolean;
highlight_color: PayrollColumnHighlightColor;
period_scope: PayrollComponentPeriodScope;
scheduled_target_component_code: string;
scheduled_application_period_id: number | null;
};
export type PayrollAdminPayRate = {