feat(finance): add scheduled payroll changes
All checks were successful
Deploy production / deploy (push) Successful in 10m1s
All checks were successful
Deploy production / deploy (push) Successful in 10m1s
This commit is contained in:
parent
d38170640b
commit
bc6a1c78ca
@ -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,
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -29,6 +29,10 @@ class PayrollComponentKind(models.TextChoices):
|
||||
ADJUSTMENT_DEBIT = "adjustment_debit", "Отрицательная корректировка"
|
||||
DEDUCTION = "deduction", "Удержать"
|
||||
PAYMENT = "payment", "Уже выплачено"
|
||||
SCHEDULED_CHANGE = (
|
||||
"scheduled_change",
|
||||
"Изменить выбранную колонку в будущем периоде",
|
||||
)
|
||||
|
||||
|
||||
class PayrollComponentPeriodScope(models.TextChoices):
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
144
backend/finance/payroll/scheduled_changes.py
Normal file
144
backend/finance/payroll/scheduled_changes.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
1300
backend/tests/finance/test_payroll_scheduled_changes.py
Normal file
1300
backend/tests/finance/test_payroll_scheduled_changes.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user