diff --git a/backend/api/v1/finance/admin/__init__.py b/backend/api/v1/finance/admin/__init__.py index b8335781..a489348b 100644 --- a/backend/api/v1/finance/admin/__init__.py +++ b/backend/api/v1/finance/admin/__init__.py @@ -27,6 +27,7 @@ from .inputs import ( PayrollWorkRecordReviseView, ) from .periods import ( + PayrollAdminComponentDetailView, PayrollAdminComponentListView, PayrollAdminEmployeeListView, PayrollAdminPeriodDetailView, @@ -65,6 +66,7 @@ __all__ = [ "EmployeePayRateDetailView", "EmployeePayRateListCreateView", "EmployeePayRateReviseView", + "PayrollAdminComponentDetailView", "PayrollAdminComponentListView", "PayrollAdminEmployeeListView", "PayrollAdminPeriodDetailView", diff --git a/backend/api/v1/finance/admin/period_views/__init__.py b/backend/api/v1/finance/admin/period_views/__init__.py index df04ec52..9d18658f 100644 --- a/backend/api/v1/finance/admin/period_views/__init__.py +++ b/backend/api/v1/finance/admin/period_views/__init__.py @@ -8,6 +8,7 @@ from .carryovers import ( PayrollPeriodCarryoverListCreateView, ) from .catalog import ( + PayrollAdminComponentDetailView, PayrollAdminComponentListView, PayrollAdminEmployeeListView, PayrollAdminPeriodDetailView, @@ -21,6 +22,7 @@ from .statements import ( ) __all__ = [ + "PayrollAdminComponentDetailView", "PayrollAdminComponentListView", "PayrollAdminEmployeeListView", "PayrollAdminPeriodDetailView", diff --git a/backend/api/v1/finance/admin/period_views/carryovers.py b/backend/api/v1/finance/admin/period_views/carryovers.py index 6dbf7ad7..0419b7bd 100644 --- a/backend/api/v1/finance/admin/period_views/carryovers.py +++ b/backend/api/v1/finance/admin/period_views/carryovers.py @@ -19,6 +19,7 @@ from finance.models import ( PayrollPeriod, ) from finance.payroll.carryovers import cancel_carryover +from finance.payroll.components import get_payroll_components from ...admin_serializers import ( PayrollCarryoverInclusionSerializer, @@ -50,7 +51,7 @@ class PayrollPeriodCarryoverListCreateView(PayrollAdminAPIView): "code": "MANUAL_CARRYOVER_DISABLED", "message": ( "Отдельные переносы не создаются. Баланс складывается из " - "положительных и отрицательных корректировок." + "значений колонок, включённых в переходящие остатки." ), }, status=status.HTTP_410_GONE, @@ -83,12 +84,17 @@ class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView): def patch(self, request, pk): command = PayrollCarryoverInclusionSerializer(data=request.data) command.is_valid(raise_exception=True) + carryover_component_codes = { + component.code + for component in get_payroll_components() + if component.include_in_carryover + } line = get_object_or_404( PayrollInputLine.objects.select_for_update(of=("self",)).select_related( "period" ), pk=pk, - component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), + component_code__in=carryover_component_codes, ) included = command.validated_data["included"] previous = line.include_in_carryover diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index ca062466..f21c8919 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -17,9 +17,12 @@ from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from finance.enums import ApprovalStatus from finance.models import ( + FinanceSettings, PayrollCustomComponent, PayrollInputCopyConfiguration, + PayrollInputLine, PayrollPeriod, ) from finance.payroll.components import ( @@ -115,6 +118,127 @@ class PayrollAdminComponentListView(PayrollAdminAPIView): ) +class PayrollAdminComponentDetailView(PayrollAdminAPIView): + serializer_class = PayrollComponentSerializer + allowed_permissions = INPUT_VIEW_PERMISSIONS + + @transaction.atomic + def patch(self, request, code): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + try: + current = get_payroll_components().get(code=code) + except LookupError as exc: + raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc + serializer = PayrollCustomComponentWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + scope_period = values.pop("scope_period", None) + existing_lines = PayrollInputLine.objects.filter(component_code=code).exclude( + status=ApprovalStatus.VOIDED + ) + period_scope = values["period_scope"] + if period_scope == "current_only": + existing_lines = existing_lines.exclude(period=scope_period) + elif period_scope == "current_and_future": + existing_lines = existing_lines.filter( + period__date_from__lt=scope_period.date_from + ) + else: + existing_lines = existing_lines.none() + if existing_lines.exists(): + _conflict( + "COMPONENT_SCOPE_HAS_INPUTS", + ( + "Новая область периодов скрывает сохранённые значения колонки. " + "Сначала очистите эти значения или оставьте колонку доступной." + ), + ) + normalized_name = values["name"].casefold() + if any( + component.code != code and component.name.casefold() == normalized_name + for component in get_payroll_components() + ): + raise ValidationError({"name": "Колонка с таким названием уже существует."}) + + custom = ( + PayrollCustomComponent.objects.select_for_update().filter(code=code).first() + ) + if custom is not None: + for field_name, value in values.items(): + setattr(custom, field_name, value) + custom.scope_period = scope_period + try: + custom.full_clean() + custom.save(update_fields=[*values, "scope_period"]) + except DjangoValidationError as exc: + raise ValidationError(_validation_detail(exc)) from exc + audit_instance = custom + else: + settings = FinanceSettings.get_solo() + settings = FinanceSettings.objects.select_for_update().get(pk=settings.pk) + overrides = dict(settings.payroll_component_overrides) + overrides[code] = { + **values, + "scope_period_id": scope_period.pk if scope_period else None, + } + settings.payroll_component_overrides = overrides + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + audit_instance = settings + + _audit_api_mutation( + actor=request.user, + action="payroll.component_updated", + instance=audit_instance, + metadata={"component_code": code, "previous_kind": current.kind}, + ) + updated = get_payroll_components().get(code=code) + return Response(PayrollComponentSerializer(updated).data) + + @transaction.atomic + def delete(self, request, code): + self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) + component = get_object_or_404( + PayrollCustomComponent.objects.select_for_update(), + code=code, + ) + period = get_object_or_404(PayrollPeriod, pk=request.data.get("period_id")) + mode = request.data.get("mode") + affected_lines = PayrollInputLine.objects.filter(component_code=code).exclude( + status=ApprovalStatus.VOIDED + ) + if mode == "current": + affected_lines = affected_lines.filter(period=period) + elif mode == "current_and_future": + affected_lines = affected_lines.filter( + period__date_from__gte=period.date_from + ) + else: + raise ValidationError( + {"mode": "Выберите текущий период или текущий и будущие."} + ) + if affected_lines.exists(): + _conflict( + "COMPONENT_HAS_INPUTS", + ( + "В выбранных периодах есть значения этой колонки. " + "Сначала очистите их, затем удалите колонку." + ), + ) + if mode == "current": + component.excluded_periods.add(period) + else: + component.hidden_from_period = period + component.save(update_fields=["hidden_from_period"]) + _audit_api_mutation( + actor=request.user, + action="payroll.component_hidden", + instance=component, + period=period, + metadata={"mode": mode}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema_view( get=extend_schema(operation_id="finance_payroll_admin_periods_list") ) diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index a1ca2628..51c198c3 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -9,7 +9,6 @@ from communications.models import Message from finance.enums import ( ApprovalStatus, InputSource, - PayrollComponentKind, PayrollPeriodStatus, ) from finance.models import ( @@ -17,6 +16,8 @@ from finance.models import ( PayrollInputLine, PayrollStatement, ) +from finance.payroll.balances import CARRYOVER_DEBIT_KINDS +from finance.payroll.components import get_payroll_components from ..admin_serializers import ( PayrollCellCommentSerializer, @@ -112,12 +113,17 @@ def _automatic_balance_summaries(rows): def _carryover_ledger(period): + carryover_components = { + component.code: component + for component in get_payroll_components() + if component.include_in_carryover + } lines = ( PayrollInputLine.objects.filter( period__currency=period.currency, period__date_to__lte=period.date_to, employee__is_hidden_in_ui=False, - component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), + component_code__in=carryover_components, ) .exclude( source=InputSource.API, @@ -144,10 +150,9 @@ def _carryover_ledger(period): and line.status == ApprovalStatus.DRAFT ) counts_in_balance = bool(line.include_in_carryover and active_status) + component = carryover_components[line.component_code] raw_contribution = ( - line.amount - if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT - else -line.amount + line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount ) contribution = raw_contribution if counts_in_balance else Decimal("0.00") balances_by_employee[line.employee_id] = ( @@ -169,7 +174,7 @@ def _carryover_ledger(period): "currency": line.period.currency, }, "employee": PayrollEmployeeMiniSerializer(line.employee).data, - "component": PayrollComponentSerializer(line.component).data, + "component": PayrollComponentSerializer(component).data, "amount": str(line.amount), "raw_contribution": str(raw_contribution), "contribution": str(contribution), diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 5fcd7e48..1447a085 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -106,6 +106,11 @@ class PayrollComponentSerializer(serializers.Serializer): 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) + include_in_point_base = serializers.BooleanField(read_only=True) + include_in_carryover = serializers.BooleanField(read_only=True) + 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) def get_id(self, obj) -> str: return obj.code @@ -144,6 +149,10 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): "name", "kind", "requires_reason", + "include_in_point_base", + "include_in_carryover", + "visible_to_employee", + "highlight_color", "period_scope", "period_id", ] diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index 0e9d6b31..a7c0f768 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -146,7 +146,7 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): source="run.published_at", read_only=True, ) - lines = PayrollStatementLineSerializer(many=True, read_only=True) + lines = serializers.SerializerMethodField() acknowledgement = serializers.SerializerMethodField() payment_confirmation = serializers.SerializerMethodField() can_confirm_payment = serializers.SerializerMethodField() @@ -189,6 +189,11 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): def get_can_confirm_payment(self, obj) -> bool: return obj.run.period.status == "closed" + @extend_schema_field(PayrollStatementLineSerializer(many=True)) + def get_lines(self, obj): + lines = obj.lines.filter(visible_to_employee=True) + return PayrollStatementLineSerializer(lines, many=True).data + class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer): status_label = serializers.CharField(source="get_status_display", read_only=True) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 7c146292..0817e8e6 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -5,6 +5,7 @@ from .admin import ( EmployeePayRateDetailView, EmployeePayRateListCreateView, EmployeePayRateReviseView, + PayrollAdminComponentDetailView, PayrollAdminComponentListView, PayrollAdminEmployeeListView, PayrollAdminPeriodDetailView, @@ -102,6 +103,11 @@ urlpatterns = [ PayrollAdminComponentListView.as_view(), name="admin-component-list", ), + path( + "payroll/admin/components//", + PayrollAdminComponentDetailView.as_view(), + name="admin-component-detail", + ), path( "payroll/admin/periods/", PayrollAdminPeriodListCreateView.as_view(), diff --git a/backend/finance/README.md b/backend/finance/README.md index 4634c5ef..6e8b9354 100644 --- a/backend/finance/README.md +++ b/backend/finance/README.md @@ -49,13 +49,14 @@ employees above or below the point target. | Аванс | payment input line, reducing payable | | Итого / выработка / перерасчёт / к выплате | reconciliation checkpoints | -Published statements persist a signed correction balance. Only explicit manual -adjustment lines change it: adjustment debits increase the balance and adjustment -credits decrease it. Point-based recalculation, ordinary earnings, deductions and -recorded payments never create a carryover. The next period uses the last published -closing balance as its opening balance until opposite corrections net it to zero. -Historical statements from before this feature start with a zero closing balance -and are not backfilled. +Published statements persist a signed carryover balance. Each input column has an +independent setting that decides whether its approved manual lines participate. +Debit-like lines (negative adjustments, deductions and payments) increase the +balance; credit-like lines (earnings and positive adjustments) decrease it. +Point-based recalculation never creates a carryover. The next period uses the last +published closing balance as its opening balance; input cell values themselves are +not copied into the next period. Historical statements from before this feature +start with a zero closing balance and are not backfilled. Checkpoint columns can block review on mismatch but never feed money back into the calculation. diff --git a/backend/finance/migrations/0035_payroll_component_settings.py b/backend/finance/migrations/0035_payroll_component_settings.py new file mode 100644 index 00000000..01bd6433 --- /dev/null +++ b/backend/finance/migrations/0035_payroll_component_settings.py @@ -0,0 +1,102 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("finance", "0034_reportingentry_local_project")] + + operations = [ + migrations.AddField( + model_name="financesettings", + name="payroll_component_overrides", + field=models.JSONField( + blank=True, + default=dict, + verbose_name="Настройки зарплатных колонок", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="include_in_point_base", + field=models.BooleanField( + default=False, + verbose_name="Учитывать в балльной базе", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="include_in_carryover", + field=models.BooleanField( + default=False, + verbose_name="Учитывать в переходящих остатках", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="hidden_from_period", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.PROTECT, + related_name="hidden_custom_components_from_period", + to="finance.payrollperiod", + verbose_name="Скрыта начиная с периода", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="highlight_color", + field=models.CharField( + blank=True, + choices=[ + ("", "Без выделения"), + ("green", "Зелёный"), + ("red", "Красный"), + ("amber", "Янтарный"), + ("blue", "Синий"), + ("violet", "Фиолетовый"), + ], + default="", + max_length=16, + verbose_name="Цвет выделения", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="excluded_periods", + field=models.ManyToManyField( + blank=True, + related_name="excluded_custom_components", + to="finance.payrollperiod", + verbose_name="Скрыта в отдельных периодах", + ), + ), + migrations.AddField( + model_name="payrollcustomcomponent", + name="visible_to_employee", + field=models.BooleanField( + default=True, + verbose_name="Показывать сотруднику в расчётке", + ), + ), + migrations.AddField( + model_name="payrollstatementline", + name="visible_to_employee", + field=models.BooleanField( + default=True, + verbose_name="Показывать сотруднику", + ), + ), + migrations.AlterField( + model_name="payrollinputline", + name="include_in_carryover", + field=models.BooleanField( + default=True, + help_text=( + "Учитывается, когда для колонки включены переходящие остатки; " + "на расчёт зарплаты самой строки не влияет и не копирует её " + "значение." + ), + verbose_name="Учитывать в переходящем остатке", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 6470fafa..e6bce453 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -66,6 +66,11 @@ class FinanceSettings(models.Model): "с финансовыми правами. Заполните список, чтобы ограничить доступ." ), ) + payroll_component_overrides = models.JSONField( + "Настройки зарплатных колонок", + default=dict, + blank=True, + ) updated_at = models.DateTimeField("Обновлено", auto_now=True) class Meta: @@ -973,7 +978,7 @@ class PayrollComponent: class PayrollCustomComponent(models.Model): - """User-created payroll column with immutable calculation semantics.""" + """User-created payroll column with configurable calculation semantics.""" code = models.CharField( "Код", @@ -993,6 +998,32 @@ class PayrollCustomComponent(models.Model): "Требовать основание", default=False, ) + include_in_point_base = models.BooleanField( + "Учитывать в балльной базе", + default=False, + ) + include_in_carryover = models.BooleanField( + "Учитывать в переходящих остатках", + default=False, + ) + visible_to_employee = models.BooleanField( + "Показывать сотруднику в расчётке", + default=True, + ) + highlight_color = models.CharField( + "Цвет выделения", + max_length=16, + blank=True, + choices=( + ("", "Без выделения"), + ("green", "Зелёный"), + ("red", "Красный"), + ("amber", "Янтарный"), + ("blue", "Синий"), + ("violet", "Фиолетовый"), + ), + default="", + ) is_active = models.BooleanField("Активна", default=True) display_order = models.PositiveIntegerField("Порядок", default=900) period_scope = models.CharField( @@ -1009,6 +1040,20 @@ class PayrollCustomComponent(models.Model): related_name="scoped_custom_components", verbose_name="Начальный период", ) + hidden_from_period = models.ForeignKey( + PayrollPeriod, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="hidden_custom_components_from_period", + verbose_name="Скрыта начиная с периода", + ) + excluded_periods = models.ManyToManyField( + PayrollPeriod, + blank=True, + related_name="excluded_custom_components", + verbose_name="Скрыта в отдельных периодах", + ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -1025,6 +1070,10 @@ class PayrollCustomComponent(models.Model): def __str__(self): return self.name + @property + def is_custom(self): + return True + def clean(self): super().clean() self.name = self.name.strip() @@ -1089,6 +1138,14 @@ class PayrollCustomComponent(models.Model): return True def applies_to_period(self, period): + if self.hidden_from_period_id and ( + period.date_from >= self.hidden_from_period.date_from + ): + return False + if self.pk and any( + excluded.pk == period.pk for excluded in self.excluded_periods.all() + ): + return False if self.period_scope == PayrollComponentPeriodScope.ALL: return True if period is None or self.scope_period_id is None: @@ -1169,8 +1226,8 @@ class PayrollInputLine(DraftVersionedModel): "Учитывать в переходящем остатке", default=True, help_text=( - "Используется только для положительных и отрицательных корректировок; " - "на расчёт зарплаты самой строки не влияет." + "Учитывается, когда для колонки включены переходящие остатки; " + "на расчёт зарплаты самой строки не влияет и не копирует её значение." ), ) created_by = models.ForeignKey( @@ -1254,6 +1311,9 @@ class PayrollInputLine(DraftVersionedModel): def component(self): from finance.payroll.components import get_payroll_component + # Historical and voided rows must remain serializable after a custom + # component is hidden from their period. Write validation below still + # resolves the component with the target period and rejects new rows. return get_payroll_component(self.component_code) @component.setter @@ -1741,6 +1801,10 @@ class PayrollStatementLine(models.Model): ) source_ref = models.CharField("Источник", max_length=255) reason = models.TextField("Основание", blank=True) + visible_to_employee = models.BooleanField( + "Показывать сотруднику", + default=True, + ) source_period_from = models.DateField( "Период-источник: начало", null=True, diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index f9db9537..2fda2a5b 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -20,10 +20,13 @@ from payroll_core import ( PayrollPeriod as CorePayrollPeriod, ) -POINT_BASE_COMPONENT_CODES = frozenset( - {"BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT"} +POINT_BASE_NEGATIVE_KINDS = frozenset( + { + "adjustment_debit", + "deduction", + "payment", + } ) -POINT_BASE_DEBIT_COMPONENT_CODES = frozenset({"CORRECTION_DEBIT"}) def _core_period(period: PayrollPeriod) -> CorePayrollPeriod: @@ -47,18 +50,30 @@ def _expected_totals(work_record: PayrollWorkRecord) -> ExpectedTotals | None: ) -def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, ...]: +def _component_map(period, component_definitions=None): + if component_definitions is not None: + return component_definitions + from .components import get_payroll_components + + return {component.code: component for component in get_payroll_components(period)} + + +def _mapped_input_lines( + input_lines: list[PayrollInputLine], + component_definitions, +) -> tuple[InputLine, ...]: mapped_lines = [] for record in input_lines: + component = component_definitions[record.component_code] source_period = None if record.relates_to_period_id: source_period = _core_period(record.relates_to_period) mapped_lines.append( InputLine( line_id=f"input:{record.pk}", - code=record.component.code, - label=record.component.name, - kind=LineKind(record.component.kind), + code=component.code, + label=component.name, + kind=LineKind(component.kind), amount=record.amount, source_ref=( record.source_ref or f"{record.source}:payroll-input:{record.pk}" @@ -66,7 +81,9 @@ def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, reason=record.reason, source_period=source_period, is_retro=source_period is not None, - include_in_carryover=record.include_in_carryover, + include_in_carryover=( + component.include_in_carryover and record.include_in_carryover + ), ) ) return tuple(mapped_lines) @@ -95,25 +112,24 @@ def _effective_point_rate( def _point_base_accrual( rate: EmployeePayRate | None, input_lines: list[PayrollInputLine], + component_definitions, ) -> Decimal: """Return the amount whose payout is proportional to actual points. - Vacation pay, one-time accruals, deductions and payments are intentionally - excluded. Manual corrections belong to the point basis and keep their - accounting sign. + Component settings explicitly decide which columns participate. By + default only the regular bonus is included; both correction columns remain + fixed changes of the final amount. """ base_amount = rate.amount if rate is not None else Decimal("0") - return base_amount + sum( - ( - -record.amount - if record.component_code in POINT_BASE_DEBIT_COMPONENT_CODES - else record.amount - for record in input_lines - if record.component_code in POINT_BASE_COMPONENT_CODES - ), - Decimal("0"), - ) + point_base_lines = Decimal("0") + for record in input_lines: + component = component_definitions[record.component_code] + 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 + return base_amount + point_base_lines def build_core_request( @@ -124,9 +140,15 @@ def build_core_request( input_lines: list[PayrollInputLine], opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, + component_definitions=None, ) -> PayrollRequest: + components_by_code = _component_map(period, component_definitions) target_points = work_record.target_points if work_record else None - point_base_accrual = _point_base_accrual(rate, input_lines) + point_base_accrual = _point_base_accrual( + rate, + input_lines, + components_by_code, + ) return PayrollRequest( employee_ref=f"employee:{rate.employee_id}", period=_core_period(period), @@ -144,7 +166,7 @@ def build_core_request( rounding=rounding, ), point_rate_overridden=rate.point_rate is not None, - lines=_mapped_input_lines(input_lines), + lines=_mapped_input_lines(input_lines, components_by_code), expected_totals=( _expected_totals(work_record) if work_record is not None else None ), @@ -161,10 +183,16 @@ def build_core_preview_request( input_lines: list[PayrollInputLine], opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, + component_definitions=None, ) -> PayrollRequest: """Build a non-persisted calculation request from draft or partial inputs.""" - point_base_accrual = _point_base_accrual(rate, input_lines) + components_by_code = _component_map(period, component_definitions) + point_base_accrual = _point_base_accrual( + rate, + input_lines, + components_by_code, + ) return PayrollRequest( employee_ref=f"employee:{employee_id}", period=_core_period(period), @@ -186,7 +214,7 @@ def build_core_preview_request( rounding=rounding, ), point_rate_overridden=bool(rate is not None and rate.point_rate is not None), - lines=_mapped_input_lines(input_lines), + lines=_mapped_input_lines(input_lines, components_by_code), expected_totals=None, ) diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py index 8fc8c381..0f2fd66c 100644 --- a/backend/finance/payroll/balances.py +++ b/backend/finance/payroll/balances.py @@ -1,52 +1,53 @@ -"""Payroll balances reconstructed from explicit correction input lines.""" +"""Payroll balances reconstructed from configured source input lines.""" from __future__ import annotations from collections.abc import Iterable from decimal import Decimal -from django.db.models import Q, Sum - from finance.enums import ApprovalStatus, InputSource from finance.models import PayrollInputLine, PayrollPeriod +from finance.payroll.components import get_payroll_components + +CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"}) def opening_balances( period: PayrollPeriod, employee_ids: Iterable[int], ) -> dict[int, Decimal]: - """Return the cumulative balance from approved earlier corrections.""" + """Return the cumulative balance from approved configured earlier lines.""" requested_ids = set(employee_ids) if not requested_ids: return {} - rows = ( + components = { + component.code: component + for component in get_payroll_components() + if component.include_in_carryover + } + if not components: + return {} + lines = ( PayrollInputLine.objects.filter( employee_id__in=requested_ids, period__currency=period.currency, period__date_to__lt=period.date_from, status=ApprovalStatus.APPROVED, include_in_carryover=True, - component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"), + component_code__in=components, ) .exclude( source=InputSource.API, source_ref__startswith="payroll-carryover:", ) - .values("employee_id") - .annotate( - credit_total=Sum( - "amount", - filter=Q(component_code="CORRECTION_CREDIT"), - default=Decimal("0.00"), - ), - debit_total=Sum( - "amount", - filter=Q(component_code="CORRECTION_DEBIT"), - default=Decimal("0.00"), - ), - ) + .values("employee_id", "component_code", "amount") ) - return { - row["employee_id"]: row["debit_total"] - row["credit_total"] for row in rows - } + balances = {} + for line in lines: + component = components[line["component_code"]] + direction = 1 if component.kind in CARRYOVER_DEBIT_KINDS else -1 + balances[line["employee_id"]] = balances.get( + line["employee_id"], Decimal("0.00") + ) + (line["amount"] * direction) + return balances diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index cd175f1b..31d88c0c 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -1,6 +1,6 @@ """Code-owned payroll input component definitions.""" -from dataclasses import dataclass +from dataclasses import dataclass, replace from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope @@ -12,6 +12,13 @@ class PayrollComponentDefinition: kind: str requires_reason: bool display_order: int + include_in_point_base: bool = False + include_in_carryover: bool = False + visible_to_employee: bool = True + period_scope: str = PayrollComponentPeriodScope.ALL + scope_period_id: int | None = None + is_custom: bool = False + highlight_color: str = "" @property def id(self) -> str: @@ -25,14 +32,6 @@ 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 @@ -42,7 +41,12 @@ PAYROLL_COMPONENTS = ( "VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20 ), PayrollComponentDefinition( - "BONUS", "Премия", PayrollComponentKind.EARNING, False, 30 + "BONUS", + "Премия", + PayrollComponentKind.EARNING, + False, + 30, + include_in_point_base=True, ), PayrollComponentDefinition( "CORRECTION_CREDIT", @@ -50,6 +54,8 @@ PAYROLL_COMPONENTS = ( PayrollComponentKind.ADJUSTMENT_CREDIT, True, 40, + include_in_carryover=True, + highlight_color="green", ), PayrollComponentDefinition( "CORRECTION_DEBIT", @@ -57,6 +63,8 @@ PAYROLL_COMPONENTS = ( PayrollComponentKind.ADJUSTMENT_DEBIT, True, 41, + include_in_carryover=True, + highlight_color="red", ), PayrollComponentDefinition( "ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50 @@ -150,34 +158,86 @@ class PayrollComponent: def get_payroll_components(period=None) -> _PayrollComponentCollection: """Return built-ins plus active user-created component columns.""" - from finance.models import PayrollCustomComponent + from finance.models import FinanceSettings, PayrollCustomComponent, PayrollPeriod + + finance_settings = FinanceSettings.objects.first() + overrides = finance_settings.payroll_component_overrides if finance_settings else {} + builtins = [] + for component in PAYROLL_COMPONENTS: + override = overrides.get(component.code, {}) + builtins.append( + replace( + component, + name=override.get("name", component.name), + kind=override.get("kind", component.kind), + requires_reason=override.get( + "requires_reason", component.requires_reason + ), + include_in_point_base=override.get( + "include_in_point_base", component.include_in_point_base + ), + include_in_carryover=override.get( + "include_in_carryover", component.include_in_carryover + ), + visible_to_employee=override.get( + "visible_to_employee", component.visible_to_employee + ), + highlight_color=override.get( + "highlight_color", component.highlight_color + ), + period_scope=override.get("period_scope", component.period_scope), + scope_period_id=override.get( + "scope_period_id", component.scope_period_id + ), + ) + ) custom = list( PayrollCustomComponent.objects.filter(is_active=True) - .select_related("scope_period") + .select_related("scope_period", "hidden_from_period") + .prefetch_related("excluded_periods") .order_by("display_order", "name", "id") ) if period is not None: + scope_period_ids = { + component.scope_period_id + for component in builtins + if component.scope_period_id is not None + } + scope_dates = dict( + PayrollPeriod.objects.filter(pk__in=scope_period_ids).values_list( + "pk", "date_from" + ) + ) + builtins = [ + component + for component in builtins + if component.period_scope == PayrollComponentPeriodScope.ALL + or ( + component.scope_period_id is not None + and ( + ( + component.period_scope + == PayrollComponentPeriodScope.CURRENT_ONLY + and component.scope_period_id == period.pk + ) + or ( + component.period_scope + == PayrollComponentPeriodScope.CURRENT_AND_FUTURE + and scope_dates.get(component.scope_period_id, period.date_from) + <= period.date_from + ) + ) + ) + ] custom = [ component for component in custom if component.applies_to_period(period) ] - return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom)) + return _PayrollComponentCollection((*builtins, *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 + component = get_payroll_components(period).filter(code=code) + if component: + return component[0] raise ValueError(f"Неизвестный вид зарплатной операции: {code}") diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 850437e0..2a529229 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -31,8 +31,7 @@ from requests_app.enums import RequestStatus from requests_app.models import Request from .adapter import ( - POINT_BASE_COMPONENT_CODES, - POINT_BASE_DEBIT_COMPONENT_CODES, + POINT_BASE_NEGATIVE_KINDS, build_core_preview_request, ) from .attendance import ( @@ -74,18 +73,17 @@ def _in_norm_point_rate(amount, target_points, *, rounding): ) -def _point_base_amount(rate_amount, component_amounts): +def _point_base_amount(rate_amount, component_amounts, component_definitions): if rate_amount in (None, ""): return None - return Decimal(str(rate_amount)) + sum( - ( - -Decimal(str(component_amounts.get(code, "0"))) - if code in POINT_BASE_DEBIT_COMPONENT_CODES - else Decimal(str(component_amounts.get(code, "0"))) - for code in POINT_BASE_COMPONENT_CODES - ), - Decimal("0"), - ) + amount = Decimal(str(rate_amount)) + for code, component_amount in component_amounts.items(): + component = component_definitions.get(code) + if not component or not component["include_in_point_base"]: + continue + direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1 + amount += Decimal(str(component_amount)) * direction + return amount def _signed_point_amount(lines): @@ -176,12 +174,34 @@ def _input_lines_by_employee(period, employee_ids): return grouped -def _component_definition(code, label, kind, order): +def _component_definition( + code, + label, + kind, + order, + *, + requires_reason=False, + include_in_point_base=False, + include_in_carryover=False, + visible_to_employee=True, + period_scope="all", + scope_period_id=None, + is_custom=False, + highlight_color="", +): return { "code": code, "label": label, "kind": kind, "display_order": order, + "requires_reason": requires_reason, + "include_in_point_base": include_in_point_base, + "include_in_carryover": include_in_carryover, + "visible_to_employee": visible_to_employee, + "period_scope": period_scope, + "scope_period_id": scope_period_id, + "is_custom": is_custom, + "highlight_color": highlight_color, } @@ -372,12 +392,21 @@ def build_payroll_period_table(period: PayrollPeriod): input_lines = _input_lines_by_employee(period, employee_ids) balances = opening_balances(period, employee_ids) components = get_payroll_components(period) + components_by_code = {component.code: component for component in components} component_definitions = { component.code: _component_definition( component.code, component.name, component.kind, component.display_order, + requires_reason=component.requires_reason, + include_in_point_base=component.include_in_point_base, + include_in_carryover=component.include_in_carryover, + visible_to_employee=component.visible_to_employee, + period_scope=component.period_scope, + scope_period_id=component.scope_period_id, + is_custom=component.is_custom, + highlight_color=component.highlight_color, ) for component in components } @@ -482,6 +511,7 @@ def build_payroll_period_table(period: PayrollPeriod): input_lines=source_lines, opening_balance=balances.get(employee.pk, Decimal("0")), rounding=preview_rules.rounding.value, + component_definitions=components_by_code, ) preview_result = calculator.calculate(preview_request, preview_rules) point_amount = _signed_point_amount(preview_result.lines) @@ -545,7 +575,11 @@ def build_payroll_period_table(period: PayrollPeriod): actual_points=actual_points, ) - point_base_amount = _point_base_amount(rate_amount, component_amounts) + point_base_amount = _point_base_amount( + rate_amount, + component_amounts, + component_definitions, + ) in_norm_point_rate = _in_norm_point_rate( point_base_amount, target_points, diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 3bd62248..140a5528 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -48,7 +48,7 @@ from .access import ( ) from .adapter import build_core_request, employee_snapshot from .balances import opening_balances -from .components import get_payroll_component +from .components import get_payroll_component, get_payroll_components from .config import ( base_rate_code, build_rules, @@ -285,6 +285,9 @@ def _calculate_current_period_inputs( ) input_lines = _input_lines_by_employee(period, employee_ids) balances = opening_balances(period, employee_ids) + component_definitions = { + component.code: component for component in get_payroll_components(period) + } requests = [] results = [] @@ -310,6 +313,7 @@ def _calculate_current_period_inputs( input_lines=input_lines[rate.employee_id], opening_balance=balances.get(rate.employee_id, Decimal("0")), rounding=active_rules.rounding.value, + component_definitions=component_definitions, ) try: result = active_calculator.calculate(request, active_rules) @@ -515,6 +519,10 @@ def calculate_period( employee_id__in=employee_ids, ) } + employee_line_visibility = { + component.code: component.visible_to_employee + for component in get_payroll_components(period) + } for request, (rate, result) in zip(requests, results, strict=True): totals = result.totals @@ -553,6 +561,10 @@ def calculate_period( amount=line.amount, source_ref=line.source_ref, reason=line.reason, + visible_to_employee=employee_line_visibility.get( + line.code, + True, + ), source_period_from=( line.source_period.start if line.source_period else None ), diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index 92c59415..2c56d026 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -290,21 +290,22 @@ class DeterministicPayrollCalculator: (line.amount for line in lines if line.kind is LineKind.PAYMENT), Decimal("0"), ) - manual_adjustment_credits = sum( + manual_carryover_credits = sum( ( line.amount for line in lines - if line.kind is LineKind.ADJUSTMENT_CREDIT + if line.kind in (LineKind.EARNING, LineKind.ADJUSTMENT_CREDIT) and not line.calculated and line.include_in_carryover ), Decimal("0"), ) - manual_adjustment_debits = sum( + manual_carryover_debits = sum( ( line.amount for line in lines - if line.kind is LineKind.ADJUSTMENT_DEBIT + if line.kind + in (LineKind.ADJUSTMENT_DEBIT, LineKind.DEDUCTION, LineKind.PAYMENT) and not line.calculated and line.include_in_carryover ), @@ -322,7 +323,7 @@ class DeterministicPayrollCalculator: payment_total=payments, payable=net_pay - payments, closing_balance=( - opening_balance + manual_adjustment_debits - manual_adjustment_credits + opening_balance + manual_carryover_debits - manual_carryover_credits ), ) diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py index dd0b5ea7..e937b93e 100644 --- a/backend/payroll_core/tests/test_calculator.py +++ b/backend/payroll_core/tests/test_calculator.py @@ -656,6 +656,7 @@ def test_balance_tracks_only_manual_corrections(period, rules): "SALARY_PAYMENT", LineKind.PAYMENT, "104000", + include_in_carryover=False, ), ), ) @@ -669,6 +670,28 @@ def test_balance_tracks_only_manual_corrections(period, rules): assert result.totals.closing_balance == Decimal("6000.00") +def test_balance_can_include_configured_earning_and_deduction(period, rules): + balance_rules = replace(rules, allow_negative_payable=True) + request = make_request( + period, + base_accrual=Decimal("0"), + lines=( + input_line("earning:1", "CUSTOM_EARNING", LineKind.EARNING, "2500"), + input_line( + "deduction:1", + "CUSTOM_DEDUCTION", + LineKind.DEDUCTION, + "700", + ), + ), + ) + + result = DeterministicPayrollCalculator().calculate(request, balance_rules) + + assert result.totals.payable == Decimal("1800.00") + assert result.totals.closing_balance == Decimal("-1800.00") + + def test_excluded_correction_changes_salary_but_not_carrying_balance(period, rules): request = make_request( period, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 8ab2a1fa..777c4319 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -20,6 +20,7 @@ from employees.constants import ( from employees.models import EmployeeAction from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollCellCommentThread, PayrollComponent, @@ -117,6 +118,10 @@ def test_manager_can_add_custom_component_column( "name": "Компенсация питания", "kind": "earning", "requires_reason": True, + "include_in_point_base": False, + "include_in_carryover": True, + "visible_to_employee": False, + "highlight_color": "blue", "period_scope": "current_and_future", "period_id": period.pk, }, @@ -130,6 +135,10 @@ def test_manager_can_add_custom_component_column( assert component["name"] == "Компенсация питания" assert component["kind"] == "earning" assert component["requires_reason"] is True + assert component["include_in_point_base"] is False + assert component["include_in_carryover"] is True + assert component["visible_to_employee"] is False + assert component["highlight_color"] == "blue" assert component["period_scope"] == "current_and_future" assert component["scope_period_id"] == period.pk assert PayrollCustomComponent.objects.filter(code=component["code"]).exists() @@ -163,6 +172,189 @@ def test_manager_can_add_custom_component_column( assert row["payable"] == "1250.00" +def test_manager_can_customize_system_component_behavior( + user_factory, + auth_client_factory, +): + manager = user_factory(email="system.component.manager@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + period = make_period(creator=manager) + + response = auth_client_factory(manager).patch( + api_url("admin-component-detail", code="BONUS"), + { + "name": "Премия по результату", + "kind": "earning", + "requires_reason": True, + "include_in_point_base": False, + "include_in_carryover": True, + "visible_to_employee": False, + "highlight_color": "violet", + "period_scope": "current_and_future", + "period_id": period.pk, + }, + format="json", + ) + + assert response.status_code == 200, response.content + assert response.json() == { + "id": "BONUS", + "code": "BONUS", + "name": "Премия по результату", + "kind": "earning", + "kind_label": "Начислить сотруднику", + "requires_reason": True, + "is_active": True, + "display_order": 30, + "period_scope": "current_and_future", + "scope_period_id": period.pk, + "include_in_point_base": False, + "include_in_carryover": True, + "visible_to_employee": False, + "is_custom": False, + "highlight_color": "violet", + } + override = FinanceSettings.get_solo().payroll_component_overrides["BONUS"] + assert override["include_in_carryover"] is True + assert override["highlight_color"] == "violet" + + +def test_custom_component_can_be_hidden_by_period_but_not_with_saved_values( + user_factory, + auth_client_factory, +): + manager = user_factory(email="delete.component.manager@example.test") + employee = user_factory(email="delete.component.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + june = make_period(creator=manager) + july = 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": "Временная колонка", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "all", + }, + format="json", + ) + assert created.status_code == 201, created.content + code = created.json()["code"] + + saved = client.post( + api_url("admin-component-cell-set", pk=june.pk), + {"employee_id": employee.pk, "component_code": code, "amount": "100"}, + format="json", + ) + assert saved.status_code == 201, saved.content + blocked = client.delete( + api_url("admin-component-detail", code=code), + {"period_id": june.pk, "mode": "current_and_future"}, + format="json", + ) + assert blocked.status_code == 409, blocked.content + assert blocked.json()["code"] == "COMPONENT_HAS_INPUTS" + + cleared = client.post( + api_url("admin-component-cell-clear", pk=june.pk), + {"employee_id": employee.pk, "component_code": code}, + format="json", + ) + assert cleared.status_code == 200, cleared.content + assert PayrollInputLine.objects.get(pk=saved.json()["id"]).status == "voided" + hidden = client.delete( + api_url("admin-component-detail", code=code), + {"period_id": june.pk, "mode": "current_and_future"}, + format="json", + ) + assert hidden.status_code == 204, hidden.content + for period in (june, july): + table = client.get(api_url("admin-period-table", pk=period.pk)) + assert code not in {item["code"] for item in table.json()["component_columns"]} + history = client.get( + api_url("admin-input-line-list"), + {"period_id": june.pk, "component_code": code}, + ) + assert history.status_code == 200, history.content + assert history.json()[0]["component"]["code"] == code + assert history.json()[0]["status"] == "voided" + + +def test_component_scope_change_cannot_orphan_saved_values( + user_factory, + auth_client_factory, +): + manager = user_factory(email="scope.guard.manager@example.test") + employee = user_factory(email="scope.guard.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + june = make_period(creator=manager) + july = 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": "Колонка с историей", + "kind": "earning", + "requires_reason": False, + "period_scope": "all", + }, + format="json", + ) + assert created.status_code == 201, created.content + code = created.json()["code"] + PayrollInputLine.objects.create( + period=june, + employee=employee, + component_code=code, + amount="100.00", + created_by=manager, + ) + + response = client.patch( + api_url("admin-component-detail", code=code), + { + "name": "Колонка с историей", + "kind": "earning", + "requires_reason": False, + "include_in_point_base": False, + "include_in_carryover": False, + "visible_to_employee": True, + "highlight_color": "", + "period_scope": "current_and_future", + "period_id": july.pk, + }, + format="json", + ) + + assert response.status_code == 409, response.content + assert response.json()["code"] == "COMPONENT_SCOPE_HAS_INPUTS" + component = PayrollCustomComponent.objects.get(code=code) + assert component.period_scope == "all" + assert component.scope_period_id is None + + @pytest.mark.parametrize( ("period_scope", "expected_visibility"), [ @@ -1866,17 +2058,17 @@ def test_period_table_includes_all_active_and_only_inactive_employees_with_work( assert inactive_with_zero_work.pk not in employee_ids -def test_period_table_preview_matches_excel_point_formula_with_bonus( +def test_period_table_preview_keeps_both_corrections_out_of_point_basis( user_factory, auth_client_factory, ): manager = user_factory(email="table.excel.formula.manager@example.test") employee = user_factory(email="table.excel.formula.employee@example.test") - grant(manager, "view_all_payroll") + grant(manager, "view_all_payroll", "manage_payroll_inputs") period = make_period(creator=manager) EmployeePayRate.objects.create( employee=employee, - amount="30000", + amount="80000", point_rate=None, effective_from=period.date_from, created_by=manager, @@ -1884,8 +2076,8 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus( PayrollWorkRecord.objects.create( period=period, employee=employee, - target_points="110", - actual_points="90", + target_points="105", + actual_points="95", created_by=manager, ) PayrollInputLine.objects.create( @@ -1895,8 +2087,25 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus( amount="10000", created_by=manager, ) + PayrollInputLine.objects.create( + period=period, + employee=employee, + component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"), + amount="5000", + reason="Временное увеличение премиальной базы", + created_by=manager, + ) + PayrollInputLine.objects.create( + period=period, + employee=employee, + component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"), + amount="20000", + reason="Фиксированное уменьшение итога", + created_by=manager, + ) - response = auth_client_factory(manager).get( + client = auth_client_factory(manager) + response = client.get( api_url("admin-period-table", pk=period.pk), ) @@ -1906,13 +2115,40 @@ def test_period_table_preview_matches_excel_point_formula_with_bonus( for item in response.json()["rows"] if item["employee"]["id"] == employee.pk ) - assert row["in_norm_point_rate"] == "363.6364" + assert row["in_norm_point_rate"] == "857.1429" assert row["point_rate"] is None - assert row["point_amount"] == "-7272.73" - assert row["gross_before_adjustments"] == "40000.00" - assert row["adjustment_total"] == "-7272.73" - assert row["gross_total"] == "32727.27" - assert row["payable"] == "32727.27" + assert row["point_amount"] == "-8571.43" + assert row["gross_before_adjustments"] == "90000.00" + assert row["adjustment_total"] == "-23571.43" + assert row["gross_total"] == "66428.57" + assert row["payable"] == "66428.57" + + configured = client.patch( + api_url("admin-component-detail", code="CORRECTION_CREDIT"), + { + "name": "Положительная корректировка", + "kind": "adjustment_credit", + "requires_reason": True, + "include_in_point_base": True, + "include_in_carryover": True, + "visible_to_employee": True, + "highlight_color": "green", + "period_scope": "all", + }, + format="json", + ) + assert configured.status_code == 200, configured.content + + configured_table = client.get(api_url("admin-period-table", pk=period.pk)).json() + configured_row = next( + item + for item in configured_table["rows"] + if item["employee"]["id"] == employee.pk + ) + assert configured_row["in_norm_point_rate"] == "904.7619" + assert configured_row["point_amount"] == "-9047.62" + assert configured_row["adjustment_total"] == "-24047.62" + assert configured_row["gross_total"] == "65952.38" def test_staff_admin_gets_full_pilot_access_and_can_manage_any_draft( diff --git a/backend/tests/finance/test_payroll_carryovers.py b/backend/tests/finance/test_payroll_carryovers.py index b424e3eb..65920b58 100644 --- a/backend/tests/finance/test_payroll_carryovers.py +++ b/backend/tests/finance/test_payroll_carryovers.py @@ -13,7 +13,12 @@ from finance.enums import ( PayrollCarryoverStatus, PayrollPeriodStatus, ) -from finance.models import PayrollComponent, PayrollInputLine, PayrollPeriod +from finance.models import ( + FinanceSettings, + PayrollComponent, + PayrollInputLine, + PayrollPeriod, +) from finance.payroll.balances import opening_balances from finance.payroll.carryovers import ( cancel_carryover, @@ -242,7 +247,7 @@ def test_manual_carryover_api_is_disabled( assert created.status_code == 410, created.content assert created.json()["code"] == "MANUAL_CARRYOVER_DISABLED" - assert "положительных и отрицательных корректировок" in created.json()["message"] + assert "колонок, включённых в переходящие остатки" in created.json()["message"] def test_carryover_ledger_lists_history_and_can_exclude_a_record( @@ -300,3 +305,50 @@ def test_carryover_ledger_lists_history_and_can_exclude_a_record( assert refreshed["summary"]["balance"] == "0.00" assert refreshed["entries"][0]["contribution"] == "0.00" assert refreshed["entries"][0]["raw_contribution"] == "3500.00" + + +def test_component_setting_adds_existing_values_to_carryover_balance( + user_factory, + auth_client_factory, +): + manager = user_factory(email="carryover.component.manager@example.test") + employee = user_factory(email="carryover.component.employee@example.test") + grant(manager, "manage_payroll_inputs", "view_all_payroll") + june = make_period(creator=manager, month=6) + july = make_period(creator=manager, month=7) + line = PayrollInputLine.objects.create( + period=june, + employee=employee, + component=PayrollComponent.objects.get(code="ONE_TIME_PAYMENT"), + amount=Decimal("2400.00"), + reason="Разовая выплата", + status=ApprovalStatus.APPROVED, + created_by=manager, + approved_by=manager, + approved_at=timezone.now(), + self_approval_overridden=True, + ) + assert line.include_in_carryover is True + assert opening_balances(july, [employee.pk]) == {} + + settings = FinanceSettings.get_solo() + settings.payroll_component_overrides = { + "ONE_TIME_PAYMENT": {"include_in_carryover": True} + } + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) + + assert opening_balances(july, [employee.pk]) == {employee.pk: Decimal("-2400.00")} + ledger = auth_client_factory(manager).get( + api_url("admin-period-carryovers", pk=july.pk) + ) + assert ledger.status_code == 200, ledger.content + assert [entry["id"] for entry in ledger.json()["entries"]] == [line.pk] + assert ledger.json()["summary"]["balance"] == "-2400.00" + + excluded = auth_client_factory(manager).patch( + api_url("admin-input-line-carryover-inclusion", pk=line.pk), + {"included": False}, + format="json", + ) + assert excluded.status_code == 200, excluded.content + assert opening_balances(july, [employee.pk]) == {} diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index abe5a701..689b3725 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -22,6 +22,7 @@ from finance.enums import ( ) from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollComponent, PayrollInputLine, @@ -443,9 +444,20 @@ def test_calculation_uses_excel_point_basis_with_bonus( assert statement.result_snapshot["warnings"] == [] -def test_calculation_applies_corrections_before_point_recalculation( +@pytest.mark.parametrize( + ("actual_points", "expected_adjustment", "expected_gross"), + ( + (Decimal("95"), Decimal("-23571.43"), Decimal("66428.57")), + (Decimal("105"), Decimal("-15000.00"), Decimal("75000.00")), + (Decimal("115"), Decimal("-6428.57"), Decimal("83571.43")), + ), +) +def test_calculation_keeps_both_manual_corrections_out_of_point_basis( june_period, payroll_users, + actual_points, + expected_adjustment, + expected_gross, ): operator = payroll_users["operator"] approver = payroll_users["input_approver"] @@ -462,7 +474,7 @@ def test_calculation_applies_corrections_before_point_recalculation( period=june_period, employee=employee, target_points=Decimal("105"), - actual_points=Decimal("95"), + actual_points=actual_points, created_by=operator, ) bonus = PayrollInputLine.objects.create( @@ -472,19 +484,28 @@ def test_calculation_applies_corrections_before_point_recalculation( amount=Decimal("10000"), created_by=operator, ) - correction = PayrollInputLine.objects.create( + positive_correction = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=PayrollComponent.objects.get(code="CORRECTION_CREDIT"), + amount=Decimal("5000"), + reason="Временное увеличение премиальной базы", + created_by=operator, + ) + negative_correction = PayrollInputLine.objects.create( period=june_period, employee=employee, component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"), amount=Decimal("20000"), - reason="Корректировка базы выработки", + reason="Фиксированное уменьшение итога", created_by=operator, ) for instance, approve in ( (rate, approve_pay_rate), (work, approve_work_record), (bonus, approve_input_line), - (correction, approve_input_line), + (positive_correction, approve_input_line), + (negative_correction, approve_input_line), ): approve( instance.pk, @@ -495,12 +516,12 @@ def test_calculation_applies_corrections_before_point_recalculation( run = calculate_period(june_period.pk, actor=operator) statement = run.statements.get(employee=employee) - assert statement.input_snapshot["point_base_accrual"] == "70000" - assert statement.input_snapshot["point_rate"] == "666.6667" + assert statement.input_snapshot["point_base_accrual"] == "90000" + assert statement.input_snapshot["point_rate"] == "857.1429" assert statement.gross_before_adjustments == Decimal("90000.00") - assert statement.adjustment_total == Decimal("-26666.67") - assert statement.gross_total == Decimal("63333.33") - assert statement.payable == Decimal("63333.33") + assert statement.adjustment_total == expected_adjustment + assert statement.gross_total == expected_gross + assert statement.payable == expected_gross def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge( @@ -921,6 +942,9 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it( auth_client_factory, extract_results, ): + settings = FinanceSettings.get_solo() + settings.payroll_component_overrides = {"BONUS": {"visible_to_employee": False}} + settings.save(update_fields=["payroll_component_overrides", "updated_at"]) run = calculate_excel_period(june_period, payroll_users) publish_calculation(run, payroll_users) statement = run.statements.get() @@ -968,7 +992,9 @@ def test_employee_api_only_returns_own_published_statement_and_no_stores_it( detail_response = employee_client.get(detail_url) assert detail_response.status_code == 200 detail = detail_response.json() - assert len(detail["lines"]) == 4 + assert len(detail["lines"]) == 3 + assert all(line["code"] != "BONUS" for line in detail["lines"]) + assert statement.lines.get(code="BONUS").visible_to_employee is False assert "opening_balance" not in detail assert "closing_balance" not in detail assert all("is_automatic_carryover" not in line for line in detail["lines"]) diff --git a/frontend/src/components/finance/admin/PayrollAdminModals.tsx b/frontend/src/components/finance/admin/PayrollAdminModals.tsx index 69322e13..946c10f9 100644 --- a/frontend/src/components/finance/admin/PayrollAdminModals.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminModals.tsx @@ -574,7 +574,7 @@ export function PayrollBulkPointRateModal({ Как в пределах нормы - Для каждого сотрудника: (оклад + премия) ÷ его норма баллов. + Для каждого сотрудника: балльная база ÷ его норма баллов. diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index a4393240..63202c90 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -82,6 +82,7 @@ import type { PayrollInputHistoryKind, PayrollPayRateWrite, PayrollPeriodTable, + PayrollPeriodTableComponent, PayrollPeriodTableRow, PayrollPeriodWrite, PayrollWorkRecordWrite, @@ -1369,6 +1370,43 @@ export function PayrollAdminWorkspace({ setNotice(`Колонка «${payload.name}» добавлена.`); }; + const updateComponentColumn = async ( + component: PayrollPeriodTableComponent, + payload: PayrollCustomComponentWrite, + ) => { + if (!canManageSourceData || !selectedPeriod) return; + await apiClient.updatePayrollAdminComponent(component.code, { + ...payload, + period_id: selectedPeriod.id, + }); + await Promise.all([ + loadWorkspace(selectedPeriod.id, true), + refreshInlineTable("inputs"), + ]); + setNotice(`Настройки колонки «${payload.name}» сохранены.`); + }; + + const deleteComponentColumn = async ( + component: PayrollPeriodTableComponent, + mode: "current" | "current_and_future", + ) => { + if (!canManageSourceData || !selectedPeriod) return; + await apiClient.hidePayrollAdminComponent( + component.code, + selectedPeriod.id, + mode, + ); + await Promise.all([ + loadWorkspace(selectedPeriod.id, true), + refreshInlineTable("inputs"), + ]); + setNotice( + mode === "current" + ? `Колонка «${component.label}» удалена из текущего периода.` + : `Колонка «${component.label}» удалена из текущего и будущих периодов.`, + ); + }; + const saveRateCell = async ( row: PayrollPeriodTableRow, field: "amount" | "point_rate", @@ -1978,7 +2016,7 @@ export function PayrollAdminWorkspace({
{sourceDataLocked ? (
@@ -2188,8 +2226,14 @@ export function PayrollAdminWorkspace({ onAddComponent={ canManageSourceData ? addComponentColumn : undefined } + onUpdateComponent={ + canManageSourceData ? updateComponentColumn : undefined + } + onDeleteComponent={ + canManageSourceData ? deleteComponentColumn : undefined + } onOpenCarryovers={(employee) => { - setCarryoversEmployeeId(employee.id); + setCarryoversEmployeeId(employee?.id || null); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={ diff --git a/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx b/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx index 6c75e397..c05d7c12 100644 --- a/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx +++ b/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx @@ -195,20 +195,19 @@ export function PayrollCarryoversModal({

- В реестре: все - положительные и отрицательные корректировки за выбранный и - предыдущие периоды. + В реестре: все строки + колонок, для которых включён параметр «Учитывать в переходящих + остатках», за выбранный и предыдущие периоды.

В остатке: сумма всех - учитываемых утверждённых корректировок за всю историю до конца - выбранного периода и активных корректировок самого выбранного - периода. + учитываемых утверждённых строк за всю историю до конца выбранного + периода и активных строк самого выбранного периода.

- Не входят: зарплата по - баллам, обычные премии и начисления, аванс, прочие выплаты и - удержания. + Это не копирование:{" "} + значение ячейки не появляется в колонке следующего периода; + меняется только переходящий остаток.

Отключение записи{" "} @@ -332,7 +331,7 @@ export function PayrollCarryoversModal({ } /> - Учитывать эту корректировку в переходящем остатке + Учитывать эту строку в переходящем остатке {savingId === entry.id ? ( @@ -346,7 +345,7 @@ export function PayrollCarryoversModal({

) : (
- Корректировок за выбранный и предыдущие периоды нет. + Учитываемых строк за выбранный и предыдущие периоды нет.
)} diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index 654a45ad..b4dca0cf 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -52,6 +52,7 @@ import { import { useMiddleMousePan } from "@/hooks/useMiddleMousePan"; import type { PayrollComponentPeriodScope, + PayrollColumnHighlightColor, PayrollCustomComponentWrite, PayrollLineKind, PayrollPeriodTable, @@ -132,6 +133,33 @@ const periodScopeLabels: Record = { all: "Все периоды", }; +const highlightClasses: Record< + PayrollColumnHighlightColor, + { column: string; text: string } +> = { + "": { column: "", text: "" }, + green: { + column: "!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035]", + text: "font-bold text-emerald-400", + }, + red: { + column: "!border-x-2 !border-red-500/80 bg-red-500/[0.035]", + text: "font-bold text-red-400", + }, + amber: { + column: "!border-x-2 !border-amber-500/80 bg-amber-500/[0.035]", + text: "font-bold text-amber-400", + }, + blue: { + column: "!border-x-2 !border-blue-500/80 bg-blue-500/[0.035]", + text: "font-bold text-blue-400", + }, + violet: { + column: "!border-x-2 !border-violet-500/80 bg-violet-500/[0.035]", + text: "font-bold text-violet-400", + }, +}; + 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 = @@ -468,6 +496,7 @@ function ColumnHeader({ help, onOpen, onBulkEdit, + onConfigure, onHide, dragHandle, }: { @@ -476,6 +505,7 @@ function ColumnHeader({ help?: CalculationHelp; onOpen: (help: CalculationHelp) => void; onBulkEdit?: () => void; + onConfigure?: () => void; onHide: () => void; dragHandle?: ReactNode; }) { @@ -499,8 +529,19 @@ function ColumnHeader({ type="button" className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]" onClick={onBulkEdit} - aria-label={`Массово заполнить колонку «${label}»`} - title="Массовое заполнение" + aria-label={`Заполнить колонку «${label}»`} + title="Заполнить колонку" + > + + + ) : null} + {onConfigure ? ( + @@ -668,18 +709,38 @@ function CalculationHelpModal({ ); } -function AddPayrollComponentModal({ - onCreate, +function PayrollComponentSettingsModal({ + component, + onSave, + onDelete, onClose, }: { - onCreate: (payload: PayrollCustomComponentWrite) => Promise; + component?: PayrollPeriodTableComponent | null; + onSave: (payload: PayrollCustomComponentWrite) => Promise; + onDelete?: (mode: "current" | "current_and_future") => 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 [name, setName] = useState(component?.label || ""); + const [kind, setKind] = useState( + component?.kind || "earning", + ); + const [requiresReason, setRequiresReason] = useState( + component?.requires_reason || false, + ); + const [includeInPointBase, setIncludeInPointBase] = useState( + component?.include_in_point_base || false, + ); + const [includeInCarryover, setIncludeInCarryover] = useState( + component?.include_in_carryover || false, + ); + const [visibleToEmployee, setVisibleToEmployee] = useState( + component?.visible_to_employee ?? true, + ); + const [highlightColor, setHighlightColor] = + useState(component?.highlight_color || ""); + const [periodScope, setPeriodScope] = useState( + component?.period_scope || "current_and_future", + ); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [helpOpen, setHelpOpen] = useState(false); @@ -718,22 +779,63 @@ function AddPayrollComponentModal({ setPending(true); setError(null); try { - await onCreate({ + await onSave({ name: normalizedName, kind, requires_reason: requiresReason, + include_in_point_base: includeInPointBase, + include_in_carryover: includeInCarryover, + visible_to_employee: visibleToEmployee, + highlight_color: highlightColor, period_scope: periodScope, }); onClose(); } catch (createError) { setError( - getPayrollAdminError(createError, "Не удалось добавить колонку."), + getPayrollAdminError( + createError, + component + ? "Не удалось сохранить колонку." + : "Не удалось добавить колонку.", + ), ); } finally { setPending(false); } }; + const remove = async (mode: "current" | "current_and_future") => { + if (!onDelete) return; + setPending(true); + setError(null); + try { + await onDelete(mode); + onClose(); + } catch (deleteError) { + setError( + getPayrollAdminError(deleteError, "Не удалось удалить колонку."), + ); + } finally { + setPending(false); + } + }; + + const previewComponent: PayrollPeriodTableComponent = { + code: component?.code || "PREVIEW", + label: name.trim() || "Новая колонка", + 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, + scope_period_id: component?.scope_period_id ?? null, + is_custom: component?.is_custom ?? true, + highlight_color: highlightColor, + }; + const calculationPreview = componentCalculationHelp(previewComponent); + if (typeof document === "undefined") return null; return createPortal(
+

+ {calculationPreview.logic} +

+
+ {calculationPreview.formula} +
+
+ + + {error ? (
{error} @@ -876,23 +1080,59 @@ function AddPayrollComponentModal({ > {pending ? ( + ) : component ? ( + ) : ( )} - Добавить + {component ? "Сохранить" : "Добавить"}
+ {component?.is_custom && onDelete ? ( +
+

+ Удаление безопасно скрывает колонку, сохраняя прошлые расчёты. +

+
+ + +
+
+ ) : null} {helpOpen ? ( - setHelpOpen(false)} /> + setHelpOpen(false)} + /> ) : null} , document.body, ); } -function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) { +function PayrollComponentKindsHelpModal({ + preview, + onClose, +}: { + preview: CalculationHelp; + onClose: () => void; +}) { const closeButtonRef = useRef(null); useEffect(() => { @@ -913,7 +1153,7 @@ function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) { aria-label="Закрыть справку о типах колонок" />
void }) { id="payroll-component-kinds-help-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]" > - Как выбрать тип колонки + Текущая формула «{preview.title}» -
+
+

+ Что изменится в расчёте +

+

+ {preview.logic} +

+
+ {preview.formula} +
+
+ +

+ Справочник типов +

+
{kindHelpItems.map((item) => (
void }) { ))}
-
+
Итоговая формула: К выплате = Начислено − Удержано − Выплачено.
@@ -974,13 +1232,13 @@ function calculationHelps( ): Record { const pointRecalculationLogic = pointPolicy === "proportional_with_excess" - ? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный." + ? "Баллы изменяют настроенную балльную базу. По умолчанию в неё входят оклад и премия; обе ручные корректировки изменяют итог фиксированной суммой. Участие любой колонки можно изменить в её настройках." : pointPolicy === "excess_only" ? "Недовыполнение нормы не уменьшает начисление; оплачивается только превышение." : "Перерасчёт по баллам отключён текущими правилами."; const pointRecalculationFormula = pointPolicy === "proportional_with_excess" - ? "Если цена сверх нормы пустая:\nПо баллам = ((Оклад + Премия) ÷ Норма × Баллы) − (Оклад + Премия)\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы" + ? "База = Оклад + Σ колонок с настройкой «Учитывать в балльной базе»\n\nЕсли цена сверх нормы пустая:\nПо баллам = (База ÷ Норма × Баллы) − База\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы" : pointPolicy === "excess_only" ? "По баллам = max(Баллы − Норма, 0) × Цена балла сверх нормы" : "По баллам = 0"; @@ -989,9 +1247,10 @@ function calculationHelps( inNormPointRate: { title: "Цена балла в норме", logic: - "Расчётная стоимость одного балла в исходной Excel-формуле. В балльную базу входят оклад и премия.", - formula: "Цена балла в норме = (Оклад + Премия) ÷ Норма", - manual: "Нет. Значение меняется через оклад, премию или норму баллов.", + "Расчётная стоимость одного балла. По умолчанию в балльную базу входят оклад и премия; состав базы изменяется в настройках колонок.", + formula: "Цена балла в норме = Балльная база ÷ Норма", + manual: + "Нет. Значение меняется через оклад, участвующие в базе колонки или норму баллов.", }, excessPointRate: { title: "Цена балла сверх нормы", @@ -1000,7 +1259,7 @@ function calculationHelps( ? "При текущем правиле балльная доплата отключена, поэтому цена не участвует в расчёте." : "Если отдельная цена не заполнена, система автоматически использует цену балла в норме. Можно задать индивидуальную цену.", formula: - "Цена сверх нормы = указанная цена\nесли пусто: (Оклад + Премия) ÷ Норма", + "Цена сверх нормы = указанная цена\nесли пусто: Балльная база ÷ Норма", manual: "Да. Пустая ячейка возвращает автоматический расчёт по цене балла в норме.", }, @@ -1050,7 +1309,7 @@ function calculationHelps( logic: pointRecalculationLogic, formula: pointRecalculationFormula, manual: - "Нет. Меняется через оклад, премию, норму и баллы; при перевыполнении также через цену сверх нормы.", + "Нет. Меняется через балльную базу, норму и баллы; при перевыполнении также через цену сверх нормы.", }, grossBeforeAdjustments: { title: "До корректировок", @@ -1126,39 +1385,65 @@ function componentCalculationHelp( earning: { effect: "увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»", - formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: не изменяется`, - balance: "В переходящий остаток не входит.", + formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате`, }, adjustment_credit: { effect: "увеличивает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»", - formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате\nПереходящий остаток: уменьшается`, - balance: - "Уменьшает накопленный остаток недоплаты; превышение создаёт отрицательный остаток.", + formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате`, }, adjustment_debit: { effect: "уменьшает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»", - formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате\nПереходящий остаток: увеличивается`, - balance: "Создаёт или увеличивает накопленный остаток недоплаты.", + formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате`, }, deduction: { effect: "увеличивает «Удержано» и тем самым уменьшает «После удержаний» и остаток «К выплате»", - formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате\nПереходящий остаток: не изменяется`, - balance: "В переходящий остаток не входит.", + formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате`, }, payment: { effect: "увеличивает «Выплачено» и тем самым уменьшает остаток «К выплате»; на начисленную зарплату не влияет", - formula: `${component.label} → Выплачено (+) → К выплате (−)\nПереходящий остаток: не изменяется`, - balance: "В переходящий остаток не входит.", + formula: `${component.label} → Выплачено (+) → К выплате (−)`, }, }[component.kind]; + const decreasesCarryover = ["earning", "adjustment_credit"].includes( + component.kind, + ); + const decreasesPointBase = [ + "adjustment_debit", + "deduction", + "payment", + ].includes(component.kind); + const pointBaseEffect = component.include_in_point_base + ? decreasesPointBase + ? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам." + : "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам." + : "На балльную базу, цену балла и перерасчёт по баллам не влияет."; + const pointBaseFormula = component.include_in_point_base + ? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»` + : `Балльная база: «${component.label}» не участвует`; + const carryoverEffect = component.include_in_carryover + ? decreasesCarryover + ? "Уменьшает переходящий остаток следующего периода; значение ячейки не копируется." + : "Увеличивает переходящий остаток следующего периода; значение ячейки не копируется." + : "Переходящий остаток следующего периода не меняет."; + const carryoverFormula = component.include_in_carryover + ? decreasesCarryover + ? `Остаток следующего периода = остаток без этой колонки − сумма «${component.label}»` + : `Остаток следующего периода = остаток без этой колонки + сумма «${component.label}»` + : `Остаток следующего периода: «${component.label}» не участвует`; + const employeeEffect = component.visible_to_employee + ? "В расчётке сотрудника колонка видна." + : "Колонка внутренняя и в расчётке сотрудника скрыта; итоговые суммы сохраняются."; + const reasonEffect = component.requires_reason + ? "При вводе обязательно основание." + : "Основание при вводе необязательно."; return { title: component.label, - logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${flow.balance}`, - formula: `${component.label} = Σ операций этого вида за период\n\nКуда идёт:\n${flow.formula}`, + logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${pointBaseEffect} ${carryoverEffect} ${employeeEffect} ${reasonEffect}`, + formula: `${component.label} = Σ операций этого вида за период\n\nИтоги текущего периода:\n${flow.formula}\n\nБалльный расчёт:\n${pointBaseFormula}\n\nПереходящие остатки:\n${carryoverFormula}`, manual: "Да. Введите итоговую сумму в ячейку: система создаст новый черновик или изменит существующий на нужную разницу. Утверждённая операция не переписывается — для исправления создаётся новая операция.", }; @@ -1503,6 +1788,8 @@ export function PayrollPeriodTableView({ onSaveWork, onSaveComponent, onAddComponent, + onUpdateComponent, + onDeleteComponent, onOpenCarryovers, onSetPaymentConfirmed, onSetReviewMark, @@ -1535,7 +1822,15 @@ export function PayrollPeriodTableView({ value: string, ) => Promise; onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise; - onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void; + onUpdateComponent?: ( + component: PayrollPeriodTableComponent, + payload: PayrollCustomComponentWrite, + ) => Promise; + onDeleteComponent?: ( + component: PayrollPeriodTableComponent, + mode: "current" | "current_and_future", + ) => Promise; + onOpenCarryovers?: (employee?: PayrollPeriodTableRow["employee"]) => void; onSetPaymentConfirmed?: ( row: PayrollPeriodTableRow, confirmed: boolean, @@ -1565,6 +1860,8 @@ export function PayrollPeriodTableView({ } | null>(null); const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); const [addComponentOpen, setAddComponentOpen] = useState(false); + const [editingComponent, setEditingComponent] = + useState(null); const [rowsMenuOpen, setRowsMenuOpen] = useState(false); const [compactPanel, setCompactPanel] = useState( "table", @@ -1858,12 +2155,8 @@ export function PayrollPeriodTableView({ : section === "footer" ? "!border-b-2" : ""; - if (component?.kind === "adjustment_credit") { - return `!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035] ${sectionEdge}`; - } - if (component?.kind === "adjustment_debit") { - return `!border-x-2 !border-red-500/80 bg-red-500/[0.035] ${sectionEdge}`; - } + if (component?.highlight_color) + return `${highlightClasses[component.highlight_color].column} ${sectionEdge}`; return groupBoundaryColumnIds.has(column.id) ? "!border-l-2 !border-l-[var(--border-strong)]" : ""; @@ -1912,12 +2205,7 @@ export function PayrollPeriodTableView({ }; const component = componentsByColumnId.get(column.id); if (component) { - const accentClass = - component.kind === "adjustment_credit" - ? "font-bold text-emerald-400" - : component.kind === "adjustment_debit" - ? "font-bold text-red-400" - : ""; + const accentClass = highlightClasses[component.highlight_color].text; return ( { + setEditingComponent(component); + setAddComponentOpen(true); + } + : undefined + } {...common} > {component.label} @@ -2082,14 +2378,17 @@ export function PayrollPeriodTableView({ return ( onOpenCarryovers(undefined) : undefined + } help={{ title: "Остаток к переносу", logic: - "Накопленная разница, созданная только явными положительными и отрицательными корректировками. После публикации она переходит в следующий период той же валюты.\n\nЗарплата по баллам, оклад, премии, отпускные, разовые начисления, удержания и выплаты этот остаток не создают.\n\nПлюс — накопленная недоплата. Минус — накопленная переплата. Ноль — корректировки полностью скомпенсированы.", + "Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках». После публикации итог становится начальным остатком следующего периода той же валюты.\n\nЗначения ячеек при этом не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата.", formula: - "Остаток на конец = Остаток на начало + Отрицательная корректировка − Положительная корректировка\n\nОстаток на начало следующего периода = Остаток на конец опубликованного периода", + "Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток следующего периода учитывает утверждённые строки предыдущих периодов", manual: - "Отдельно не редактируется.\n• Отрицательная корректировка создаёт или увеличивает недоплату.\n• Положительная корректировка погашает недоплату; превышение создаёт переплату.\n• Противоположные корректировки на одинаковую сумму сводят остаток к нулю.", + "Сам итог отдельно не редактируется. Карандаш в заголовке открывает реестр, где отдельную строку можно включить или исключить. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».", }} {...common} > @@ -2992,14 +3291,7 @@ export function PayrollPeriodTableView({ className="h-3 w-3 rounded-sm border-2 border-emerald-500" aria-hidden="true" /> - Зелёная рамка — положительная корректировка - - - setAddComponentOpen(true)} + onClick={() => { + setEditingComponent(null); + setAddComponentOpen(true); + }} aria-label="Добавить колонку начисления или выплаты" title="Добавить колонку" > @@ -3270,9 +3565,22 @@ export function PayrollPeriodTableView({ /> ) : null} {addComponentOpen && onAddComponent ? ( - setAddComponentOpen(false)} + + editingComponent && onUpdateComponent + ? onUpdateComponent(editingComponent, payload) + : onAddComponent(payload) + } + onDelete={ + editingComponent?.is_custom && onDeleteComponent + ? (mode) => onDeleteComponent(editingComponent, mode) + : undefined + } + onClose={() => { + setAddComponentOpen(false); + setEditingComponent(null); + }} /> ) : null} {breakdownEmployee ? ( diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index c7ab7b10..94a094b7 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -392,6 +392,14 @@ export type PayrollPeriodTableComponent = { label: string; kind: PayrollLineKind; display_order: number; + requires_reason: boolean; + include_in_point_base: boolean; + include_in_carryover: boolean; + visible_to_employee: boolean; + period_scope: PayrollComponentPeriodScope; + scope_period_id: number | null; + is_custom: boolean; + highlight_color: PayrollColumnHighlightColor; }; export type PayrollCellCommentAuthor = { @@ -606,12 +614,29 @@ export type PayrollComponent = { display_order: number; period_scope: PayrollComponentPeriodScope; scope_period_id: number | null; + include_in_point_base: boolean; + include_in_carryover: boolean; + visible_to_employee: boolean; + is_custom: boolean; + highlight_color: PayrollColumnHighlightColor; }; +export type PayrollColumnHighlightColor = + | "" + | "green" + | "red" + | "amber" + | "blue" + | "violet"; + export type PayrollCustomComponentWrite = { name: string; kind: PayrollLineKind; requires_reason: boolean; + include_in_point_base: boolean; + include_in_carryover: boolean; + visible_to_employee: boolean; + highlight_color: PayrollColumnHighlightColor; period_scope: PayrollComponentPeriodScope; }; @@ -1026,7 +1051,7 @@ function buildQuery( } function jsonOptions( - method: "POST" | "PATCH", + method: "POST" | "PATCH" | "DELETE", payload: object = {}, ): RequestInit { return { method, body: JSON.stringify(payload) }; @@ -1089,6 +1114,22 @@ export function createFinanceApi(request: RequestFn) { payload: PayrollCustomComponentWrite & { period_id: number }, ): Promise => request(`${adminPrefix}/components/`, jsonOptions("POST", payload)), + updatePayrollAdminComponent: ( + code: string, + payload: PayrollCustomComponentWrite & { period_id: number }, + ): Promise => + request( + `${adminPrefix}/components/${encodeURIComponent(code)}/`, + jsonOptions("PATCH", payload), + ), + hidePayrollAdminComponent: ( + code: string, + periodId: number, + mode: "current" | "current_and_future", + ): Promise => + request(`${adminPrefix}/components/${encodeURIComponent(code)}/`, { + ...jsonOptions("DELETE", { period_id: periodId, mode }), + }), getPayrollAdminPeriods: (): Promise< PayrollAdminListPayload > => request(`${adminPrefix}/periods/`),