diff --git a/backend/api/v1/finance/admin/common.py b/backend/api/v1/finance/admin/common.py index 038ee351..9aa2c411 100644 --- a/backend/api/v1/finance/admin/common.py +++ b/backend/api/v1/finance/admin/common.py @@ -23,9 +23,7 @@ from finance.models import ( PayrollAuditEvent, PayrollPeriod, ) -from finance.payroll.components import ( - PAYROLL_COMPONENTS_BY_CODE, -) +from finance.payroll.components import get_payroll_component from finance.payroll.access import ( can_access_payroll_management, has_payroll_permission, @@ -146,15 +144,19 @@ def _payroll_history_action_label(action): return "Состояние записи изменено" -def _validate_payroll_table_column(column_id): +def _validate_payroll_table_column(column_id, *, period=None): if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS: return column_id prefix, separator, component_code = column_id.partition(":") + try: + component = get_payroll_component(component_code, period=period) + except ValueError: + component = None if ( prefix == "component" and separator and component_code not in SYSTEM_COMPONENT_CODES - and component_code in PAYROLL_COMPONENTS_BY_CODE + and component is not None ): return column_id raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."}) diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 29bb6caa..48db2cfb 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -15,7 +15,7 @@ from finance.models import ( PayrollInputCopyConfiguration, PayrollInputLine, ) -from finance.payroll.components import PAYROLL_COMPONENTS, PAYROLL_COMPONENTS_BY_CODE +from finance.payroll.components import get_payroll_components from ..admin_serializers import ( PayrollComponentSerializer, @@ -42,13 +42,20 @@ def _validate_input_copy_periods(target_period, source_period): ) -def _copyable_input_lines(source_period): +def _copyable_input_lines(source_period, *, target_period=None): + component_codes = { + component.code for component in get_payroll_components(source_period) + } + if target_period is not None: + component_codes &= { + component.code for component in get_payroll_components(target_period) + } return ( PayrollInputLine.objects.filter( period=source_period, employee__is_hidden_in_ui=False, status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED), - component_code__in=PAYROLL_COMPONENTS_BY_CODE, + component_code__in=component_codes, ) .exclude( source=InputSource.API, @@ -109,7 +116,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)) + lines = list( + _copyable_input_lines(source_period, target_period=target_period) + ) component_rows = { component.code: { "component": component, @@ -117,7 +126,7 @@ def _input_copy_preview(target_period, source_period, *, actor): "employee_ids": set(), "amount_total": Decimal("0.00"), } - for component in PAYROLL_COMPONENTS + for component in get_payroll_components(target_period) } employee_rows = {} for line in lines: @@ -228,7 +237,10 @@ def _apply_input_copy(*, target_period, source_period, actor, data): "idempotent_replay": True, } lines = list( - _copyable_input_lines(source_period).filter( + _copyable_input_lines( + source_period, + target_period=target_period, + ).filter( component_code__in=data["component_codes"] ) ) diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 7f8430ce..a874333f 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -18,15 +18,17 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response from finance.models import ( + PayrollCustomComponent, PayrollInputCopyConfiguration, PayrollPeriod, ) from finance.payroll.components import ( - PAYROLL_COMPONENTS, + get_payroll_components, ) from ...admin_serializers import ( PayrollComponentSerializer, + PayrollCustomComponentWriteSerializer, PayrollEmployeeMiniSerializer, PayrollPeriodSerializer, PayrollPeriodWriteSerializer, @@ -39,6 +41,7 @@ from ..common import ( PayrollAdminAPIView, _audit_api_mutation, _conflict, + _optional_int, ) from ..input_copy import _apply_input_copy @@ -61,7 +64,54 @@ class PayrollAdminComponentListView(PayrollAdminAPIView): allowed_permissions = INPUT_VIEW_PERMISSIONS def get(self, request): - return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data) + period_id = _optional_int(request, "period_id") + period = ( + get_object_or_404(PayrollPeriod, pk=period_id) + if period_id is not None + else None + ) + return Response( + PayrollComponentSerializer( + get_payroll_components(period), + many=True, + ).data + ) + + @transaction.atomic + def post(self, request): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + serializer = PayrollCustomComponentWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + next_order = ( + PayrollCustomComponent.objects.order_by("-display_order") + .values_list("display_order", flat=True) + .first() + or 890 + ) + 10 + component = PayrollCustomComponent( + **serializer.validated_data, + display_order=next_order, + created_by=request.user, + ) + try: + component.full_clean() + component.save(force_insert=True) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + except IntegrityError as exc: + raise ValidationError( + {"non_field_errors": "Не удалось сохранить колонку."} + ) from exc + _audit_api_mutation( + actor=request.user, + action="payroll.component_created", + instance=component, + metadata={"kind": component.kind}, + ) + return Response( + PayrollComponentSerializer(component).data, + status=status.HTTP_201_CREATED, + ) @extend_schema_view( diff --git a/backend/api/v1/finance/admin/period_views/comments.py b/backend/api/v1/finance/admin/period_views/comments.py index c2171ebf..8cdcafe7 100644 --- a/backend/api/v1/finance/admin/period_views/comments.py +++ b/backend/api/v1/finance/admin/period_views/comments.py @@ -43,7 +43,10 @@ class PayrollCellCommentsView(PayrollAdminAPIView): def get_context(self, pk, employee_id, column_id): period = get_object_or_404(PayrollPeriod, pk=pk) employee = get_object_or_404(Employee, pk=employee_id) - return period, employee, _validate_payroll_table_column(column_id) + return period, employee, _validate_payroll_table_column( + column_id, + period=period, + ) def get(self, request, pk, employee_id, column_id): period, employee, column_id = self.get_context( @@ -94,7 +97,7 @@ class PayrollCellCommentDetailView(PayrollAdminAPIView): def delete(self, request, pk, employee_id, column_id, comment_id): period = get_object_or_404(PayrollPeriod, pk=pk) employee = get_object_or_404(Employee, pk=employee_id) - column_id = _validate_payroll_table_column(column_id) + column_id = _validate_payroll_table_column(column_id, period=period) thread = get_object_or_404( PayrollCellCommentThread, period=period, diff --git a/backend/api/v1/finance/admin/workspace.py b/backend/api/v1/finance/admin/workspace.py index 92f7095c..0caf00c7 100644 --- a/backend/api/v1/finance/admin/workspace.py +++ b/backend/api/v1/finance/admin/workspace.py @@ -13,7 +13,7 @@ from finance.payroll.access import ( has_payroll_permission, has_simple_admin_access, ) -from finance.payroll.components import PAYROLL_COMPONENTS +from finance.payroll.components import get_payroll_components from ...employees.serializers import EmployeeBriefSerializer from ..admin_serializers import ( @@ -119,7 +119,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): many=True, ).data, "components": PayrollComponentSerializer( - PAYROLL_COMPONENTS, + get_payroll_components(selected_period), many=True, ).data, "periods": PayrollPeriodSerializer(periods, many=True).data, diff --git a/backend/api/v1/finance/payroll_serializers/__init__.py b/backend/api/v1/finance/payroll_serializers/__init__.py index 33a255bf..96819a01 100644 --- a/backend/api/v1/finance/payroll_serializers/__init__.py +++ b/backend/api/v1/finance/payroll_serializers/__init__.py @@ -14,6 +14,7 @@ from .common import ( PayrollCellCommentSerializer, PayrollCellCommentWriteSerializer, PayrollComponentSerializer, + PayrollCustomComponentWriteSerializer, PayrollEmployeeMiniSerializer, PayrollUserRefSerializer, ) @@ -79,6 +80,7 @@ __all__ = [ "PayrollCellCommentWriteSerializer", "PayrollCompleteWorkflowSerializer", "PayrollComponentSerializer", + "PayrollCustomComponentWriteSerializer", "PayrollEmployeeMiniSerializer", "PayrollInputLineSerializer", "PayrollInputLineWriteSerializer", diff --git a/backend/api/v1/finance/payroll_serializers/commands.py b/backend/api/v1/finance/payroll_serializers/commands.py index 670829c1..f180905d 100644 --- a/backend/api/v1/finance/payroll_serializers/commands.py +++ b/backend/api/v1/finance/payroll_serializers/commands.py @@ -12,9 +12,7 @@ from decimal import Decimal from django.contrib.auth import get_user_model from rest_framework import serializers -from finance.payroll.components import ( - PAYROLL_COMPONENT_CODE_CHOICES, -) +from .common import PayrollComponentCodeField Employee = get_user_model() @@ -25,13 +23,11 @@ class ApprovalCommandSerializer(serializers.Serializer): class ClearComponentCellCommandSerializer(serializers.Serializer): employee_id = serializers.IntegerField(min_value=1) - component_code = serializers.ChoiceField( - choices=PAYROLL_COMPONENT_CODE_CHOICES, + component_code = PayrollComponentCodeField( required=False, ) - component_id = serializers.ChoiceField( + component_id = PayrollComponentCodeField( source="component_code", - choices=PAYROLL_COMPONENT_CODE_CHOICES, required=False, write_only=True, ) @@ -57,13 +53,13 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer): class CopyPayrollInputLinesCommandSerializer(serializers.Serializer): source_period_id = serializers.IntegerField(min_value=1) component_codes = serializers.ListField( - child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), + child=PayrollComponentCodeField(), allow_empty=False, required=False, ) component_ids = serializers.ListField( source="component_codes", - child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES), + child=PayrollComponentCodeField(), allow_empty=False, required=False, write_only=True, diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 99814ff9..5fcd7e48 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -15,7 +15,10 @@ from api.v1.employees.serializers import EmployeeBriefSerializer from finance.enums import ( ApprovalStatus, InputSource, + PayrollComponentPeriodScope, ) +from finance.models import PayrollCustomComponent, PayrollPeriod +from finance.payroll.components import get_payroll_component Employee = get_user_model() @@ -93,7 +96,7 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer): class PayrollComponentSerializer(serializers.Serializer): - id = serializers.CharField(read_only=True) + id = serializers.SerializerMethodField() code = serializers.CharField(read_only=True) name = serializers.CharField(read_only=True) kind = serializers.CharField(read_only=True) @@ -101,6 +104,60 @@ class PayrollComponentSerializer(serializers.Serializer): requires_reason = serializers.BooleanField(read_only=True) is_active = serializers.BooleanField(read_only=True) display_order = serializers.IntegerField(read_only=True) + period_scope = serializers.CharField(read_only=True) + scope_period_id = serializers.IntegerField(read_only=True, allow_null=True) + + def get_id(self, obj) -> str: + return obj.code + + +class PayrollComponentCodeField(serializers.CharField): + default_error_messages = { + "unknown": "Неизвестный вид зарплатной операции.", + } + + def to_internal_value(self, data): + value = super().to_internal_value(data) + try: + get_payroll_component(value) + except ValueError: + self.fail("unknown") + return value + + +class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): + name = serializers.CharField(max_length=80, trim_whitespace=True) + period_scope = serializers.ChoiceField( + choices=PayrollComponentPeriodScope.choices, + default=PayrollComponentPeriodScope.CURRENT_AND_FUTURE, + ) + period_id = serializers.PrimaryKeyRelatedField( + source="scope_period", + queryset=PayrollPeriod.objects.all(), + required=False, + write_only=True, + ) + + class Meta: + model = PayrollCustomComponent + fields = [ + "name", + "kind", + "requires_reason", + "period_scope", + "period_id", + ] + + def validate(self, attrs): + attrs = super().validate(attrs) + scope = attrs["period_scope"] + if scope == PayrollComponentPeriodScope.ALL: + attrs.pop("scope_period", None) + elif attrs.get("scope_period") is None: + raise serializers.ValidationError( + {"period_id": "Для выбранной области укажите расчётный период."} + ) + return attrs class DraftWriteSerializerMixin: diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index 02da1f25..ac369c4e 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -17,16 +17,14 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, ) -from finance.payroll.components import ( - PAYROLL_COMPONENT_CODE_CHOICES, -) - from .common import ( DraftWriteSerializerMixin, + PayrollComponentCodeField, PayrollComponentSerializer, PayrollEmployeeMiniSerializer, PayrollUserRefSerializer, ) +from finance.payroll.components import get_payroll_component from .periods import PayrollCarryoverPeriodSerializer Employee = get_user_model() @@ -106,13 +104,11 @@ class PayrollInputLineWriteSerializer( source="employee", queryset=Employee.objects.visible_in_ui(), ) - component_code = serializers.ChoiceField( - choices=PAYROLL_COMPONENT_CODE_CHOICES, + component_code = PayrollComponentCodeField( required=False, ) - component_id = serializers.ChoiceField( + component_id = PayrollComponentCodeField( source="component_code", - choices=PAYROLL_COMPONENT_CODE_CHOICES, required=False, write_only=True, ) @@ -137,6 +133,14 @@ class PayrollInputLineWriteSerializer( ] def validate(self, attrs): - if not attrs.get("component_code"): + component_code = attrs.get("component_code") + if not component_code: raise serializers.ValidationError({"component_code": "Обязательное поле."}) + period = attrs.get("period") or getattr(self.instance, "period", None) + try: + get_payroll_component(component_code, period=period) + except ValueError as exc: + raise serializers.ValidationError( + {"component_code": "Колонка недоступна в выбранном периоде."} + ) from exc return attrs diff --git a/backend/finance/enums.py b/backend/finance/enums.py index faad5e08..41eb8599 100644 --- a/backend/finance/enums.py +++ b/backend/finance/enums.py @@ -31,6 +31,12 @@ class PayrollComponentKind(models.TextChoices): PAYMENT = "payment", "Выплата" +class PayrollComponentPeriodScope(models.TextChoices): + CURRENT_ONLY = "current_only", "Только выбранный период" + CURRENT_AND_FUTURE = "current_and_future", "Выбранный и будущие периоды" + ALL = "all", "Все периоды" + + class PayrollPeriodStatus(models.TextChoices): OPEN = "open", "Открыт" CALCULATED = "calculated", "Рассчитан" diff --git a/backend/finance/migrations/0027_payrollcustomcomponent.py b/backend/finance/migrations/0027_payrollcustomcomponent.py new file mode 100644 index 00000000..e3b3e0ee --- /dev/null +++ b/backend/finance/migrations/0027_payrollcustomcomponent.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.4 on 2026-08-26 10:37 + +import django.core.validators +import django.db.models.deletion +import finance.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0026_alter_employeepayrate_source_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PayrollCustomComponent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')), + ('name', models.CharField(max_length=80, unique=True, verbose_name='Название')), + ('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')), + ('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ], + options={ + 'verbose_name': 'Пользовательская зарплатная колонка', + 'verbose_name_plural': 'Пользовательские зарплатные колонки', + 'ordering': ['display_order', 'name', 'id'], + }, + ), + ] diff --git a/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py new file mode 100644 index 00000000..305652f4 --- /dev/null +++ b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.4 on 2026-08-26 11:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0027_payrollcustomcomponent'), + ] + + operations = [ + migrations.AddField( + model_name='payrollcustomcomponent', + name='period_scope', + field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'), + ), + migrations.AddField( + model_name='payrollcustomcomponent', + name='scope_period', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'), + ), + ] diff --git a/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py new file mode 100644 index 00000000..2fba19cb --- /dev/null +++ b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-08-26 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0028_payrollcustomcomponent_period_scope_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='payrollcustomcomponent', + name='name', + field=models.CharField(max_length=80, verbose_name='Название'), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 75268f94..8121f03a 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -21,6 +21,7 @@ from .enums import ( PayrollCarryoverDirection, PayrollCarryoverStatus, PayrollComponentKind, + PayrollComponentPeriodScope, PayrollPeriodStatus, PayrollRunStatus, VacationPayInstructionStatus, @@ -42,6 +43,10 @@ payroll_cell_column_validator = RegexValidator( DEFAULT_DAILY_TARGET_POINTS = Decimal("5") +def generate_custom_payroll_component_code() -> str: + return f"CUSTOM_{uuid.uuid4().hex.upper()}" + + class FinanceSettings(models.Model): """Global access settings for the native finance management workspace.""" @@ -920,6 +925,132 @@ class PayrollComponent: objects = _PayrollComponentRegistryManager() +class PayrollCustomComponent(models.Model): + """User-created payroll column with immutable calculation semantics.""" + + code = models.CharField( + "Код", + max_length=64, + unique=True, + default=generate_custom_payroll_component_code, + editable=False, + validators=[component_code_validator], + ) + name = models.CharField("Название", max_length=80) + kind = models.CharField( + "Влияние на расчёт", + max_length=24, + choices=PayrollComponentKind.choices, + ) + requires_reason = models.BooleanField( + "Требовать основание", + default=False, + ) + is_active = models.BooleanField("Активна", default=True) + display_order = models.PositiveIntegerField("Порядок", default=900) + period_scope = models.CharField( + "Периоды отображения", + max_length=24, + choices=PayrollComponentPeriodScope.choices, + default=PayrollComponentPeriodScope.ALL, + ) + scope_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="scoped_custom_components", + verbose_name="Начальный период", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_payroll_components", + verbose_name="Создал", + ) + created_at = models.DateTimeField("Создана", auto_now_add=True) + + class Meta: + verbose_name = "Пользовательская зарплатная колонка" + verbose_name_plural = "Пользовательские зарплатные колонки" + ordering = ["display_order", "name", "id"] + + def __str__(self): + return self.name + + def clean(self): + super().clean() + self.name = self.name.strip() + if not self.name: + raise ValidationError({"name": "Укажите название колонки."}) + if self.period_scope == PayrollComponentPeriodScope.ALL: + self.scope_period = None + elif self.scope_period_id is None: + raise ValidationError( + {"scope_period": "Для выбранной области укажите расчётный период."} + ) + + if not self.is_active: + return + + from finance.payroll.components import PAYROLL_COMPONENTS + + normalized_name = self.name.casefold() + has_builtin_conflict = any( + component.name.casefold() == normalized_name + for component in PAYROLL_COMPONENTS + ) + custom_components = ( + PayrollCustomComponent.objects.filter(is_active=True) + .exclude(pk=self.pk) + .select_related("scope_period") + ) + has_custom_conflict = any( + component.name.casefold() == normalized_name + and self.period_scope_overlaps(component) + for component in custom_components + ) + if has_builtin_conflict or has_custom_conflict: + raise ValidationError( + { + "name": ( + "Колонка с таким названием уже существует " + "в одном из выбранных периодов." + ) + } + ) + + def period_scope_overlaps(self, other): + """Return whether two component columns can appear in the same period.""" + + if ( + self.period_scope == PayrollComponentPeriodScope.ALL + or other.period_scope == PayrollComponentPeriodScope.ALL + ): + return True + if self.scope_period_id is None or other.scope_period_id is None: + return False + if ( + self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY + and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY + ): + return self.scope_period_id == other.scope_period_id + if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY: + return self.scope_period.date_from >= other.scope_period.date_from + if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY: + return other.scope_period.date_from >= self.scope_period.date_from + return True + + def applies_to_period(self, period): + if self.period_scope == PayrollComponentPeriodScope.ALL: + return True + if period is None or self.scope_period_id is None: + 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 + + class PayrollInputLine(DraftVersionedModel): """Append-only bonus, correction, deduction or recorded payment input.""" @@ -1098,7 +1229,12 @@ class PayrollInputLine(DraftVersionedModel): } ) try: - component = self.component + from finance.payroll.components import get_payroll_component + + component = get_payroll_component( + self.component_code, + period=self.period if self.period_id else None, + ) except ValueError as exc: raise ValidationError({"component_code": str(exc)}) from exc if component.requires_reason and not self.reason: diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 79859525..1e9c46f2 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from finance.enums import PayrollComponentKind +from finance.enums import PayrollComponentPeriodScope @dataclass(frozen=True, slots=True) @@ -25,6 +26,14 @@ class PayrollComponentDefinition: def is_active(self) -> bool: return True + @property + def period_scope(self) -> str: + return PayrollComponentPeriodScope.ALL + + @property + def scope_period_id(self) -> None: + return None + def get_kind_display(self) -> str: return PayrollComponentKind(self.kind).label @@ -92,11 +101,54 @@ class _PayrollComponentCollection(tuple): class PayrollComponent: """Compatibility facade over the code registry; this is not a database model.""" - objects = _PayrollComponentCollection(PAYROLL_COMPONENTS) + class _Manager: + def all(self): + return get_payroll_components() + + def get(self, **criteria): + return self.all().get(**criteria) + + def filter(self, **criteria): + return self.all().filter(**criteria) + + def exclude(self, **criteria): + return self.all().exclude(**criteria) + + def order_by(self, *fields): + return self.all().order_by(*fields) + + objects = _Manager() -def get_payroll_component(code: str) -> PayrollComponentDefinition: - try: - return PAYROLL_COMPONENTS_BY_CODE[code] - except KeyError as exc: - raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc +def get_payroll_components(period=None) -> _PayrollComponentCollection: + """Return built-ins plus active user-created component columns.""" + + from finance.models import PayrollCustomComponent + + custom = list( + PayrollCustomComponent.objects.filter(is_active=True) + .select_related("scope_period") + .order_by("display_order", "name", "id") + ) + if period is not None: + custom = [component for component in custom if component.applies_to_period(period)] + return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom)) + + +def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition: + component = PAYROLL_COMPONENTS_BY_CODE.get(code) + if component is not None: + return component + + from finance.models import PayrollCustomComponent + + component = ( + PayrollCustomComponent.objects.filter(code=code, is_active=True) + .select_related("scope_period") + .first() + ) + if component is not None and ( + period is None or component.applies_to_period(period) + ): + return component + raise ValueError(f"Неизвестный вид зарплатной операции: {code}") diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 4807efce..382c5cb1 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -29,7 +29,7 @@ from finance.models import ( PayrollWorkRecord, PayrollWorkSettings, ) -from finance.payroll.components import PAYROLL_COMPONENTS +from finance.payroll.components import get_payroll_components from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request from .balances import opening_balances @@ -371,7 +371,7 @@ def build_payroll_period_table(period: PayrollPeriod): work_records = _latest_work_by_employee(period, employee_ids) input_lines = _input_lines_by_employee(period, employee_ids) balances = opening_balances(period, employee_ids) - components = PAYROLL_COMPONENTS + components = get_payroll_components(period) component_definitions = { component.code: _component_definition( component.code, diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 439a0b24..3fd7ac7e 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -55,6 +55,7 @@ from .config import ( ruleset_not_effective_message, ruleset_period_details, ) +from .components import get_payroll_component from .exceptions import PayrollOperationError, PayrollPermissionDenied from .work_norm import calculate_period_target_points @@ -1994,13 +1995,21 @@ def set_component_input_line_value( ) -> PayrollInputLine: """Replace the aggregate value of one editable spreadsheet component cell.""" + period = PayrollPeriod.objects.select_for_update().get(pk=period_id) + try: + get_payroll_component(component_code, period=period) + except ValueError as exc: + raise PayrollOperationError( + "COMPONENT_NOT_AVAILABLE", + "Колонка недоступна в выбранном расчётном периоде.", + details={"component_code": component_code}, + ) from exc clear_component_input_lines( period_id, employee_id=employee_id, component_code=component_code, actor=actor, ) - period = PayrollPeriod.objects.select_for_update().get(pk=period_id) line = PayrollInputLine( period=period, employee_id=employee_id, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index b5dbb240..5eb9be45 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -23,6 +23,7 @@ from finance.models import ( PayrollAuditEvent, PayrollCellCommentThread, PayrollComponent, + PayrollCustomComponent, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, @@ -98,6 +99,236 @@ def make_approved_period_inputs( ) +def test_manager_can_add_custom_component_column( + user_factory, + auth_client_factory, +): + manager = user_factory(email="component.manager@example.test") + employee = user_factory(email="component.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + client = auth_client_factory(manager) + + created = client.post( + api_url("admin-component-list"), + { + "name": "Компенсация питания", + "kind": "earning", + "requires_reason": True, + "period_scope": "current_and_future", + "period_id": period.pk, + }, + format="json", + ) + + assert created.status_code == 201, created.content + component = created.json() + assert component["id"] == component["code"] + assert component["code"].startswith("CUSTOM_") + assert component["name"] == "Компенсация питания" + assert component["kind"] == "earning" + assert component["requires_reason"] is True + assert component["period_scope"] == "current_and_future" + assert component["scope_period_id"] == period.pk + assert PayrollCustomComponent.objects.filter(code=component["code"]).exists() + + saved = client.post( + api_url("admin-component-cell-set", pk=period.pk), + { + "employee_id": employee.pk, + "component_code": component["code"], + "amount": "1250.00", + }, + format="json", + ) + assert saved.status_code == 201, saved.content + line = PayrollInputLine.objects.get(pk=saved.json()["id"]) + assert line.component.name == "Компенсация питания" + assert line.reason == "Перезаписано в итоговой таблице" + + table = client.get(api_url("admin-period-table", pk=period.pk)) + assert table.status_code == 200, table.content + assert component["code"] in { + item["code"] for item in table.json()["component_columns"] + } + row = next( + item for item in table.json()["rows"] if item["employee"]["id"] == employee.pk + ) + assert row["component_amounts"] == {component["code"]: "1250.00"} + assert row["gross_before_adjustments"] == "1250.00" + assert row["gross_total"] == "1250.00" + assert row["net_pay"] == "1250.00" + assert row["payable"] == "1250.00" + + +@pytest.mark.parametrize( + ("period_scope", "expected_visibility"), + [ + ("current_only", (False, True, False)), + ("current_and_future", (False, True, True)), + ("all", (True, True, True)), + ], +) +def test_custom_component_period_scope_controls_table_visibility( + user_factory, + auth_client_factory, + period_scope, + expected_visibility, +): + manager = user_factory(email=f"scope.{period_scope}@example.test") + employee = user_factory(email=f"scope.employee.{period_scope}@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + periods = [ + PayrollPeriod.objects.create( + code="2026-05", + name="Май 2026", + date_from="2026-05-01", + date_to="2026-05-31", + pay_date="2026-06-05", + currency="RUB", + created_by=manager, + ), + make_period(creator=manager), + PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ), + ] + client = auth_client_factory(manager) + created = client.post( + api_url("admin-component-list"), + { + "name": f"Колонка {period_scope}", + "kind": "payment", + "requires_reason": False, + "period_scope": period_scope, + "period_id": periods[1].pk, + }, + format="json", + ) + assert created.status_code == 201, created.content + component_code = created.json()["code"] + + visibility = [] + for period in periods: + response = client.get(api_url("admin-period-table", pk=period.pk)) + assert response.status_code == 200, response.content + visibility.append( + component_code + in {item["code"] for item in response.json()["component_columns"]} + ) + assert tuple(visibility) == expected_visibility + + unavailable_period = next( + ( + period + for period, visible in zip(periods, expected_visibility, strict=True) + if not visible + ), + None, + ) + if unavailable_period is not None: + rejected = client.post( + api_url("admin-component-cell-set", pk=unavailable_period.pk), + { + "employee_id": employee.pk, + "component_code": component_code, + "amount": "500.00", + }, + format="json", + ) + assert rejected.status_code == 409, rejected.content + assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE" + assert not PayrollInputLine.objects.filter( + period=unavailable_period, + employee=employee, + component_code=component_code, + ).exists() + + +def test_custom_component_name_is_unique_only_in_overlapping_periods( + user_factory, + auth_client_factory, +): + manager = user_factory(email="scope.name.manager@example.test") + grant(manager, "manage_payroll_inputs") + periods = [ + PayrollPeriod.objects.create( + code="2026-05", + name="Май 2026", + date_from="2026-05-01", + date_to="2026-05-31", + pay_date="2026-06-05", + currency="RUB", + created_by=manager, + ), + make_period(creator=manager), + PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=manager, + ), + ] + client = auth_client_factory(manager) + + def create_component(*, period, period_scope, name="Компенсация"): + return client.post( + api_url("admin-component-list"), + { + "name": name, + "kind": "earning", + "requires_reason": False, + "period_scope": period_scope, + "period_id": period.pk, + }, + format="json", + ) + + may_only = create_component( + period=periods[0], + period_scope="current_only", + ) + june_only = create_component( + period=periods[1], + period_scope="current_only", + name="КОМПЕНСАЦИЯ", + ) + from_july = create_component( + period=periods[2], + period_scope="current_and_future", + ) + + assert may_only.status_code == 201, may_only.content + assert june_only.status_code == 201, june_only.content + assert from_july.status_code == 201, from_july.content + + overlapping_requests = [ + create_component(period=periods[2], period_scope="current_only"), + create_component(period=periods[1], period_scope="current_and_future"), + create_component(period=periods[0], period_scope="all"), + ] + for response in overlapping_requests: + assert response.status_code == 400, response.content + assert "выбранных периодов" in response.json()["name"][0] + + builtin_conflict = create_component( + period=periods[0], + period_scope="current_only", + name="ПРЕМИЯ", + ) + assert builtin_conflict.status_code == 400, builtin_conflict.content + assert "выбранных периодов" in builtin_conflict.json()["name"][0] + + def test_copy_input_lines_previews_scope_and_handles_existing_values( user_factory, auth_client_factory, diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index e2087f07..9af80f4b 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -72,6 +72,7 @@ import type { PayrollBulkPointRateResult, PayrollBulkTargetPointsResult, PayrollCalculationBlocker, + PayrollCustomComponentWrite, PayrollInputLineWrite, PayrollInputCopyResult, PayrollInputHistoryEvent, @@ -902,6 +903,19 @@ export function PayrollAdminWorkspace({ } }; + const addComponentColumn = async (payload: PayrollCustomComponentWrite) => { + if (!canManageSourceData || !selectedPeriod) return; + await apiClient.createPayrollAdminComponent({ + ...payload, + period_id: selectedPeriod.id, + }); + await Promise.all([ + loadWorkspace(selectedPeriod.id, true), + refreshInlineTable("inputs"), + ]); + setNotice(`Колонка «${payload.name}» добавлена.`); + }; + const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => { if (!canManageSourceData || !selectedPeriod) return; const rawValue = value.trim().replace(",", "."); @@ -1254,7 +1268,7 @@ export function PayrollAdminWorkspace({ {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
- setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} /> + setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
) : null} {tab === "approval" ? ( diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index 853f50e6..a33f97d4 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -16,8 +16,8 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react"; -import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, Plus, RotateCcw, Search, Table2, X } from "lucide-react"; +import { Fragment, type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal"; @@ -27,6 +27,9 @@ import { } from "@/components/finance/admin/PayrollCellCommentsModal"; import { useMiddleMousePan } from "@/hooks/useMiddleMousePan"; import type { + PayrollComponentPeriodScope, + PayrollCustomComponentWrite, + PayrollLineKind, PayrollPeriodTable, PayrollPeriodTableComponent, PayrollPeriodTableRow, @@ -52,6 +55,44 @@ const kindLabels = { payment: "Выплата", }; +const kindHelpItems: Array<{ + kind: PayrollLineKind; + description: string; + formula: string; +}> = [ + { + kind: "earning", + description: "Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.", + formula: "Увеличивает «Начислено» и «К выплате».", + }, + { + kind: "adjustment_credit", + description: "Доплата, которая исправляет или уточняет уже рассчитанные начисления.", + formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».", + }, + { + kind: "adjustment_debit", + description: "Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.", + formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».", + }, + { + kind: "deduction", + description: "Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.", + formula: "Уменьшает «После удержаний» и «К выплате».", + }, + { + kind: "payment", + description: "Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.", + formula: "Уменьшает только остаток «К выплате», не меняя начисления и удержания.", + }, +]; + +const periodScopeLabels: Record = { + current_only: "Только текущий период", + current_and_future: "Текущий и будущие периоды", + all: "Все периоды", +}; + const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]"; const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums"; const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns"; @@ -415,6 +456,177 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp; ); } +function AddPayrollComponentModal({ + onCreate, + onClose, +}: { + onCreate: (payload: PayrollCustomComponentWrite) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(""); + const [kind, setKind] = useState("earning"); + const [requiresReason, setRequiresReason] = useState(false); + const [periodScope, setPeriodScope] = useState("current_and_future"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + const nameRef = useRef(null); + const helpOpenRef = useRef(false); + + useEffect(() => { + helpOpenRef.current = helpOpen; + }, [helpOpen]); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + nameRef.current?.focus(); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (helpOpenRef.current) { + setHelpOpen(false); + return; + } + if (!pending) onClose(); + }; + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("keydown", closeOnEscape); + previouslyFocused?.focus(); + }; + }, [onClose, pending]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const normalizedName = name.trim(); + if (!normalizedName) { + setError("Укажите название колонки."); + return; + } + setPending(true); + setError(null); + try { + await onCreate({ + name: normalizedName, + kind, + requires_reason: requiresReason, + period_scope: periodScope, + }); + onClose(); + } catch (createError) { + setError(getPayrollAdminError(createError, "Не удалось добавить колонку.")); + } finally { + setPending(false); + } + }; + + if (typeof document === "undefined") return null; + return createPortal( +
+ + +
+ + +
+ + + + + {error ?
{error}
: null} +
+ + +
+
+ + {helpOpen ? setHelpOpen(false)} /> : null} + , + document.body, + ); +} + +function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) { + const closeButtonRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + return () => previouslyFocused?.focus(); + }, []); + + return ( +
+ +
+ +
+ {kindHelpItems.map((item) => ( +
+

{kindLabels[item.kind]}

+

{item.description}

+

{item.formula}

+
+ ))} +
+ +
+ Итоговая формула: + К выплате = Начислено − Удержано − Выплачено. +
+ + + ); +} + function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record { const pointRecalculationLogic = pointPolicy === "proportional_with_excess" ? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный." @@ -834,6 +1046,7 @@ export function PayrollPeriodTableView({ onSaveRate, onSaveWork, onSaveComponent, + onAddComponent, onOpenCarryovers, onSetPaymentConfirmed, compact = false, @@ -851,6 +1064,7 @@ export function PayrollPeriodTableView({ onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise; onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise; onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise; + onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise; onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void; onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise; compact?: boolean; @@ -872,6 +1086,7 @@ export function PayrollPeriodTableView({ displayName: string; } | null>(null); const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); + const [addComponentOpen, setAddComponentOpen] = useState(false); const [rowsMenuOpen, setRowsMenuOpen] = useState(false); const [compactPanel, setCompactPanel] = useState("table"); const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState>(new Set()); @@ -1580,7 +1795,20 @@ export function PayrollPeriodTableView({ Сотрудник {groupSegments.map((segment, index) => ( 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}> - {segment.group} + + {segment.group} + {onAddComponent && segment.group === "Начисления и выплаты" && groupSegments.findIndex((item) => item.group === segment.group) === index ? ( + + ) : null} + ))} @@ -1660,6 +1888,7 @@ export function PayrollPeriodTableView({ : null} {activeHelp ? setActiveHelp(null)} /> : null} + {addComponentOpen && onAddComponent ? setAddComponentOpen(false)} /> : null} {breakdownEmployee ? ( => request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`), + createPayrollAdminComponent: ( + payload: PayrollCustomComponentWrite & { period_id: number }, + ): Promise => + request(`${adminPrefix}/components/`, jsonOptions("POST", payload)), getPayrollAdminPeriods: (): Promise> => request(`${adminPrefix}/periods/`), getPayrollAdminPeriodTable: (periodId: number): Promise => diff --git a/frontend/src/lib/payroll-columns.test.js b/frontend/src/lib/payroll-columns.test.js index 803cf5df..4f1f9e66 100644 --- a/frontend/src/lib/payroll-columns.test.js +++ b/frontend/src/lib/payroll-columns.test.js @@ -6,13 +6,37 @@ import { normalizePayrollColumnOrder, } from "./payroll-columns.ts"; -test("payroll column order keeps saved columns and appends newly available ones", () => { +test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => { assert.deepEqual( normalizePayrollColumnOrder( ["payable", "actual_points", "removed", "payable"], ["rate_amount", "actual_points", "payable", "status"], ), - ["payable", "actual_points", "rate_amount", "status"], + ["payable", "rate_amount", "actual_points", "status"], + ); +}); + +test("new payroll component stays before the calculation group", () => { + assert.deepEqual( + normalizePayrollColumnOrder( + ["rate_amount", "component:BONUS", "gross_total", "payable", "status"], + [ + "rate_amount", + "component:BONUS", + "component:CUSTOM_ADVANCE", + "gross_total", + "payable", + "status", + ], + ), + [ + "rate_amount", + "component:BONUS", + "component:CUSTOM_ADVANCE", + "gross_total", + "payable", + "status", + ], ); }); diff --git a/frontend/src/lib/payroll-columns.ts b/frontend/src/lib/payroll-columns.ts index b6f6a084..6836d31f 100644 --- a/frontend/src/lib/payroll-columns.ts +++ b/frontend/src/lib/payroll-columns.ts @@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder( seen.add(columnId); return true; }); - for (const columnId of availableIds) { - if (!seen.has(columnId)) normalized.push(columnId); + for (const [availableIndex, columnId] of availableIds.entries()) { + if (seen.has(columnId)) continue; + + const nextKnownId = availableIds + .slice(availableIndex + 1) + .find((candidateId) => seen.has(candidateId)); + if (nextKnownId) { + normalized.splice(normalized.indexOf(nextKnownId), 0, columnId); + } else { + normalized.push(columnId); + } + seen.add(columnId); } return normalized; }