From 3da3100328d0e293b83f632dfe82ecdcefa4a4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 15 Oct 2026 19:37:51 +0300 Subject: [PATCH] Separate salary participation and preserve historical carryover rules --- .../v1/finance/admin/period_views/catalog.py | 19 +- backend/api/v1/finance/admin/queries.py | 6 +- .../api/v1/finance/admin/workspace_payload.py | 11 +- .../v1/finance/payroll_serializers/common.py | 5 + .../v1/finance/payroll_serializers/periods.py | 1 + .../payroll_serializers/presentation.py | 1 + backend/api/v1/finance/serializers.py | 24 +- ..._salary_participation_and_balance_rules.py | 126 ++++++++ backend/finance/models.py | 18 ++ backend/finance/payroll/adapter.py | 6 +- backend/finance/payroll/balances.py | 10 +- backend/finance/payroll/components.py | 35 ++- backend/finance/payroll/reporting.py | 32 +- backend/finance/payroll/services.py | 53 +++- backend/payroll_core/balances.py | 12 + backend/payroll_core/calculator.py | 87 ++++-- backend/payroll_core/domain.py | 24 ++ .../tests/test_salary_participation.py | 107 +++++++ .../tests/finance/test_payroll_admin_api.py | 1 + .../tests/finance/test_payroll_services.py | 2 +- .../finance/test_salary_participation.py | 283 ++++++++++++++++++ .../finance/PayrollStatementDetailModal.tsx | 15 + .../finance/admin/PayrollPeriodTable.tsx | 97 ++++-- frontend/src/lib/api/finance.ts | 10 +- frontend/src/lib/payroll-point-base.test.js | 9 + frontend/src/lib/payroll-point-base.ts | 7 +- 26 files changed, 932 insertions(+), 69 deletions(-) create mode 100644 backend/finance/migrations/0041_salary_participation_and_balance_rules.py create mode 100644 backend/payroll_core/balances.py create mode 100644 backend/payroll_core/tests/test_salary_participation.py create mode 100644 backend/tests/finance/test_salary_participation.py diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 7c9a6c67..bb6a3dfe 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -148,7 +148,14 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): current = get_payroll_components().get(code=code) except LookupError as exc: raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc - serializer = PayrollCustomComponentWriteSerializer(data=request.data) + serializer = PayrollCustomComponentWriteSerializer( + data={ + **request.data, + "include_in_salary": request.data.get( + "include_in_salary", current.include_in_salary + ), + } + ) serializer.is_valid(raise_exception=True) operation_period = PayrollPeriod.objects.filter( pk=request.data.get("period_id") @@ -156,6 +163,16 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView): _reject_closed_component_period(operation_period) values = dict(serializer.validated_data) scope_period = values.pop("scope_period", None) + if ( + operation_period is not None + and operation_period.balance_rules_version == 1 + and values["include_in_salary"] != current.include_in_salary + ): + _conflict( + "PERIOD_COMPONENTS_LOCKED", + "Участие в зарплате можно изменить только в периоде с новыми правилами. Предыдущий расчёт сохранён.", + period_id=operation_period.pk, + ) if ( values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE and values.get("scheduled_target_component_code") == code diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index 5b612291..bab98d54 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -19,8 +19,8 @@ from finance.models import ( PayrollPeriod, PayrollStatement, ) -from finance.payroll.balances import CARRYOVER_DEBIT_KINDS from finance.payroll.components import get_payroll_components +from payroll_core.balances import carryover_contribution from ..admin_serializers import ( PayrollCellCommentSerializer, @@ -160,8 +160,8 @@ def _carryover_ledger(period): and line.status == ApprovalStatus.DRAFT ) counts_in_balance = bool(line.include_in_carryover and active_status) - raw_contribution = ( - line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount + raw_contribution = carryover_contribution( + component.kind, line.amount, version=line.period.balance_rules_version ) contribution = raw_contribution if counts_in_balance else Decimal("0.00") balances_by_employee[line.employee_id] = ( diff --git a/backend/api/v1/finance/admin/workspace_payload.py b/backend/api/v1/finance/admin/workspace_payload.py index 816ad8b2..0d294cde 100644 --- a/backend/api/v1/finance/admin/workspace_payload.py +++ b/backend/api/v1/finance/admin/workspace_payload.py @@ -9,6 +9,7 @@ from finance.models import ( PayrollRun, PayrollWorkRecord, ) +from finance.payroll.components import get_payroll_components from finance.payroll.config import ( base_rate_code, build_rules, @@ -111,7 +112,15 @@ def _period_input_state(period) -> PeriodInputState: employee__is_hidden_in_ui=False, ).count() approved_input_outside_roster_employee_ids = sorted( - set(approved_inputs.values_list("employee_id", flat=True)) + set( + approved_inputs.exclude( + component_code__in=[ + component.code + for component in get_payroll_components(period) + if not component.include_in_salary + ] + ).values_list("employee_id", flat=True) + ) - set(roster_employee_ids) ) draft_inputs = PayrollInputLine.objects.filter( diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py index 60eb675e..260346f8 100644 --- a/backend/api/v1/finance/payroll_serializers/common.py +++ b/backend/api/v1/finance/payroll_serializers/common.py @@ -122,6 +122,7 @@ class PayrollComponentSerializer(serializers.Serializer): 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_salary = 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) @@ -169,6 +170,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): "kind", "requires_reason", "include_in_point_base", + "include_in_salary", "include_in_carryover", "visible_to_employee", "highlight_color", @@ -187,6 +189,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): {"period_id": "Для выбранной области укажите расчётный период."} ) if attrs["kind"] == PayrollComponentKind.SCHEDULED_CHANGE: + attrs["include_in_salary"] = False attrs["include_in_point_base"] = False attrs["include_in_carryover"] = False attrs["visible_to_employee"] = False @@ -232,6 +235,8 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer): ) else: attrs["scheduled_target_component_code"] = "" + if attrs.get("include_in_salary") is False: + attrs["include_in_point_base"] = False return attrs diff --git a/backend/api/v1/finance/payroll_serializers/periods.py b/backend/api/v1/finance/payroll_serializers/periods.py index f817d3a6..c0040c85 100644 --- a/backend/api/v1/finance/payroll_serializers/periods.py +++ b/backend/api/v1/finance/payroll_serializers/periods.py @@ -45,6 +45,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer): "status", "status_label", "current_run_id", + "balance_rules_version", "lock_version", "created_by_id", "created_at", diff --git a/backend/api/v1/finance/payroll_serializers/presentation.py b/backend/api/v1/finance/payroll_serializers/presentation.py index 46d8e893..20bae5ee 100644 --- a/backend/api/v1/finance/payroll_serializers/presentation.py +++ b/backend/api/v1/finance/payroll_serializers/presentation.py @@ -109,6 +109,7 @@ class PayrollPeriodTableSerializer(serializers.Serializer): recorded_payments = serializers.JSONField(read_only=True) period_id = serializers.IntegerField(read_only=True) period_status = serializers.CharField(read_only=True) + balance_rules_version = serializers.IntegerField(read_only=True) currency = serializers.CharField(read_only=True) calculation_rules = serializers.JSONField(read_only=True) run = serializers.JSONField(read_only=True, allow_null=True) diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index a7c0f768..b32dafc8 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -14,6 +14,11 @@ from finance.models import ( class PayrollStatementLineSerializer(serializers.ModelSerializer): + include_in_salary = serializers.SerializerMethodField() + + def get_include_in_salary(self, obj) -> bool: + return self.context.get("salary_flags", {}).get(obj.line_id, True) + class Meta: model = PayrollStatementLine fields = [ @@ -25,6 +30,7 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer): "source_period_to", "is_retro", "calculated", + "include_in_salary", ] @@ -140,6 +146,15 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): class OwnPayrollStatementSerializer(serializers.ModelSerializer): + non_salary_payment_total = serializers.SerializerMethodField() + + def get_non_salary_payment_total(self, obj) -> str: + return str( + (obj.result_snapshot or {}) + .get("totals", {}) + .get("non_salary_payment_total", "0") + ) + period = serializers.SerializerMethodField() revision = serializers.IntegerField(source="run.revision", read_only=True) published_at = serializers.DateTimeField( @@ -166,6 +181,7 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): "deduction_total", "net_pay", "payment_total", + "non_salary_payment_total", "payable", "lines", "acknowledgement", @@ -192,7 +208,13 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): @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 + salary_flags = { + line["line_id"]: line.get("include_in_salary", True) + for line in (obj.result_snapshot or {}).get("lines", []) + } + return PayrollStatementLineSerializer( + lines, many=True, context={**self.context, "salary_flags": salary_flags} + ).data class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer): diff --git a/backend/finance/migrations/0041_salary_participation_and_balance_rules.py b/backend/finance/migrations/0041_salary_participation_and_balance_rules.py new file mode 100644 index 00000000..15a8b9b1 --- /dev/null +++ b/backend/finance/migrations/0041_salary_participation_and_balance_rules.py @@ -0,0 +1,126 @@ +"""Preserve all historical rules; opt in only uncalculated periods after them.""" + +from importlib import import_module + +import django.core.validators +from django.db import migrations, models +from django.db.models import Max, Q + + +def activate_current_rules(apps, schema_editor): + Period = apps.get_model("finance", "PayrollPeriod") + for currency in Period.objects.values_list("currency", flat=True).distinct(): + frozen_end = ( + Period.objects.filter(currency=currency) + .filter(~Q(status="open") | Q(current_run__isnull=False)) + .aggregate(value=Max("date_to"))["value"] + ) + current = Period.objects.filter( + currency=currency, status="open", current_run__isnull=True + ) + if frozen_end: + current = current.filter(date_from__gt=frozen_end) + current.update(balance_rules_version=2) + + # Preserve live configuration for older unclosed/calculated periods too. + # Reuse the immutable definitions shipped in the earlier snapshot migration. + legacy = import_module("finance.migrations.0038_payrollperiod_component_snapshot") + Settings = apps.get_model("finance", "FinanceSettings") + Custom = apps.get_model("finance", "PayrollCustomComponent") + settings = Settings.objects.first() + overrides = settings.payroll_component_overrides if settings else {} + builtins = [] + for values in legacy.BUILTIN_COMPONENTS: + payload = dict( + zip( + ( + "code", + "name", + "kind", + "requires_reason", + "display_order", + "include_in_point_base", + "include_in_carryover", + "visible_to_employee", + "highlight_color", + ), + values, + strict=True, + ) + ) + payload.update( + period_scope="all", + scope_period_id=None, + is_custom=False, + scheduled_target_component_code="", + ) + payload.update(overrides.get(payload["code"], {})) + builtins.append({field: payload[field] for field in legacy.SNAPSHOT_FIELDS}) + custom = [] + for component in Custom.objects.filter(is_active=True).prefetch_related( + "excluded_periods" + ): + payload = { + field: getattr(component, field) + for field in legacy.SNAPSHOT_FIELDS + if field != "is_custom" + } + payload.update( + is_custom=True, + hidden_from_period_id=component.hidden_from_period_id, + excluded_period_ids={item.pk for item in component.excluded_periods.all()}, + ) + custom.append(payload) + dates = dict(Period.objects.values_list("pk", "date_from")) + for period in Period.objects.filter(balance_rules_version=1): + if period.component_snapshot: + continue + components = [ + payload + for payload in builtins + if legacy._applies_to_period(payload, period, dates) + ] + components.extend( + {field: payload[field] for field in legacy.SNAPSHOT_FIELDS} + for payload in custom + if legacy._applies_to_period( + payload, period, dates, payload["excluded_period_ids"] + ) + ) + Period.objects.filter(pk=period.pk).update( + component_snapshot={"version": 1, "components": components} + ) + + +class Migration(migrations.Migration): + dependencies = [("finance", "0040_payrollbalanceadjustment")] + operations = [ + migrations.AddField( + model_name="payrollcustomcomponent", + name="include_in_salary", + field=models.BooleanField( + default=True, verbose_name="Учитывать в расчёте зарплаты" + ), + ), + migrations.AddField( + model_name="payrollperiod", + name="balance_rules_version", + field=models.PositiveSmallIntegerField( + default=1, + editable=False, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="Версия правил переходящего остатка", + ), + ), + migrations.RunPython(activate_current_rules, migrations.RunPython.noop), + migrations.AlterField( + model_name="payrollperiod", + name="balance_rules_version", + field=models.PositiveSmallIntegerField( + default=2, + editable=False, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="Версия правил переходящего остатка", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 8bb7cf7f..11bc34dd 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -105,6 +105,12 @@ class PayrollPeriod(models.Model): name = models.CharField("Название", max_length=120, blank=True) date_from = models.DateField("Начало периода") date_to = models.DateField("Конец периода") + balance_rules_version = models.PositiveSmallIntegerField( + "Версия правил переходящего остатка", + default=2, + editable=False, + validators=[MinValueValidator(1)], + ) pay_date = models.DateField("Дата выплаты", null=True, blank=True) currency = models.CharField( "Валюта", @@ -193,6 +199,7 @@ class PayrollPeriod(models.Model): self.date_to, self.pay_date, self.currency, + self.balance_rules_version, ) original_values = ( original.code, @@ -201,6 +208,7 @@ class PayrollPeriod(models.Model): original.date_to, original.pay_date, original.currency, + original.balance_rules_version, ) if immutable_values != original_values: raise ValidationError( @@ -230,6 +238,10 @@ class PayrollPeriod(models.Model): update_fields = kwargs.get("update_fields") if self.pk and not self._state.adding: original = PayrollPeriod.objects.select_for_update().get(pk=self.pk) + if original.balance_rules_version != self.balance_rules_version: + raise ValidationError( + "Версию правил существующего периода нельзя изменять." + ) self._validate_immutable_fields(original) if ( original.status == PayrollPeriodStatus.CLOSED @@ -1019,6 +1031,9 @@ class PayrollCustomComponent(models.Model): "Учитывать в балльной базе", default=False, ) + include_in_salary = models.BooleanField( + "Учитывать в расчёте зарплаты", default=True + ) include_in_carryover = models.BooleanField( "Учитывать в переходящих остатках", default=False, @@ -1101,6 +1116,8 @@ class PayrollCustomComponent(models.Model): def clean(self): super().clean() self.name = self.name.strip() + if not self.include_in_salary: + self.include_in_point_base = False if not self.name: raise ValidationError({"name": "Укажите название колонки."}) if self.period_scope == PayrollComponentPeriodScope.ALL: @@ -1149,6 +1166,7 @@ class PayrollCustomComponent(models.Model): } ) self.include_in_point_base = False + self.include_in_salary = False self.include_in_carryover = False self.visible_to_employee = False elif self.scheduled_target_component_code: diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 5d04d81a..258bc956 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -102,6 +102,7 @@ def _mapped_input_lines( kind=LineKind(component.kind), amount=amount, source_ref="scheduled-effective-value", + include_in_salary=component.include_in_salary, reason="; ".join( dict.fromkeys(record.reason for record in records if record.reason) ), @@ -131,6 +132,7 @@ def _mapped_input_lines( reason=record.reason, source_period=source_period, is_retro=source_period is not None, + include_in_salary=component.include_in_salary, include_in_carryover=( component.include_in_carryover and record.include_in_carryover ), @@ -175,7 +177,7 @@ def _point_base_accrual( point_base_lines = Decimal("0") for record in input_lines: component = component_definitions[record.component_code] - if not component.include_in_point_base: + if not component.include_in_point_base or not component.include_in_salary: continue direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1 if component.kind == PayrollComponentKind.SCHEDULED_CHANGE: @@ -271,6 +273,7 @@ def build_core_request( ) return PayrollRequest( employee_ref=f"employee:{rate.employee_id}", + balance_rules_version=period.balance_rules_version, period=_core_period(period), currency=period.currency, base_accrual=rate.amount, @@ -315,6 +318,7 @@ def build_core_preview_request( ) return PayrollRequest( employee_ref=f"employee:{employee_id}", + balance_rules_version=period.balance_rules_version, period=_core_period(period), currency=period.currency, base_accrual=rate.amount if rate is not None else Decimal("0"), diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py index 3c660a6f..fdb268eb 100644 --- a/backend/finance/payroll/balances.py +++ b/backend/finance/payroll/balances.py @@ -8,8 +8,7 @@ from decimal import Decimal from finance.enums import ApprovalStatus, InputSource from finance.models import PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod from finance.payroll.components import get_payroll_components - -CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"}) +from payroll_core.balances import carryover_contribution def opening_balances( @@ -56,10 +55,13 @@ def opening_balances( ) if component is None: continue - 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) + ) + carryover_contribution( + component.kind, + line["amount"], + version=source_periods[line["period_id"]].balance_rules_version, + ) # Effective-period corrections enter the opening balance exactly once per # cumulative reconstruction. They never become payroll input lines. for adjustment in PayrollBalanceAdjustment.objects.filter( diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py index 2ee89f79..f9f3ac5e 100644 --- a/backend/finance/payroll/components.py +++ b/backend/finance/payroll/components.py @@ -8,7 +8,7 @@ from finance.enums import ( PayrollPeriodStatus, ) -PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1 +PAYROLL_COMPONENT_SNAPSHOT_VERSION = 2 PAYROLL_COMPONENT_SNAPSHOT_FIELDS = ( "code", "name", @@ -16,6 +16,7 @@ PAYROLL_COMPONENT_SNAPSHOT_FIELDS = ( "requires_reason", "display_order", "include_in_point_base", + "include_in_salary", "include_in_carryover", "visible_to_employee", "period_scope", @@ -34,6 +35,7 @@ class PayrollComponentDefinition: requires_reason: bool display_order: int include_in_point_base: bool = False + include_in_salary: bool = True include_in_carryover: bool = False visible_to_employee: bool = True period_scope: str = PayrollComponentPeriodScope.ALL @@ -198,6 +200,7 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection: include_in_point_base=override.get( "include_in_point_base", component.include_in_point_base ), + include_in_salary=override.get("include_in_salary", True), include_in_carryover=override.get( "include_in_carryover", component.include_in_carryover ), @@ -265,13 +268,27 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection: custom = [ component for component in custom if component.applies_to_period(period) ] + # Old periods retain their original salary semantics even if a live + # column is subsequently excluded from salary in the current period. + if period.balance_rules_version == 1: + return _PayrollComponentCollection( + PayrollComponentDefinition( + **{ + field: True + if field == "include_in_salary" + else getattr(component, field) + for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS + } + ) + for component in (*builtins, *custom) + ) return _PayrollComponentCollection((*builtins, *custom)) def build_payroll_component_snapshot(period) -> dict[str, object]: """Serialize the exact component configuration visible when a period closes.""" - components = _get_live_payroll_components(period) + components = get_payroll_components(period) return { "version": PAYROLL_COMPONENT_SNAPSHOT_VERSION, "components": [ @@ -287,7 +304,7 @@ def build_payroll_component_snapshot(period) -> dict[str, object]: def _components_from_snapshot(snapshot) -> _PayrollComponentCollection: if not isinstance(snapshot, dict): raise ValueError("Некорректный снимок зарплатных колонок.") - if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION: + if snapshot.get("version") not in (1, PAYROLL_COMPONENT_SNAPSHOT_VERSION): raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.") payloads = snapshot.get("components") if not isinstance(payloads, list): @@ -295,7 +312,12 @@ def _components_from_snapshot(snapshot) -> _PayrollComponentCollection: try: return _PayrollComponentCollection( PayrollComponentDefinition( - **{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS} + **{ + field: payload.get(field, True) + if field == "include_in_salary" and snapshot["version"] == 1 + else payload[field] + for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS + } ) for payload in payloads ) @@ -308,7 +330,10 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection: if ( period is not None - and period.status == PayrollPeriodStatus.CLOSED + and ( + period.status == PayrollPeriodStatus.CLOSED + or period.balance_rules_version == 1 + ) and period.component_snapshot ): return _components_from_snapshot(period.component_snapshot) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 339a030e..e1340508 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -81,7 +81,11 @@ def _point_base_amount(rate_amount, component_amounts, component_definitions): 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"]: + if ( + not component + or not component["include_in_point_base"] + or not component["include_in_salary"] + ): continue direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1 amount += Decimal(str(component_amount)) * direction @@ -184,6 +188,7 @@ def _component_definition( *, requires_reason=False, include_in_point_base=False, + include_in_salary=True, include_in_carryover=False, visible_to_employee=True, period_scope="all", @@ -199,6 +204,7 @@ def _component_definition( "display_order": order, "requires_reason": requires_reason, "include_in_point_base": include_in_point_base, + "include_in_salary": include_in_salary, "include_in_carryover": include_in_carryover, "visible_to_employee": visible_to_employee, "period_scope": period_scope, @@ -413,6 +419,7 @@ def build_payroll_period_table(period: PayrollPeriod): component.display_order, requires_reason=component.requires_reason, include_in_point_base=component.include_in_point_base, + include_in_salary=component.include_in_salary, include_in_carryover=component.include_in_carryover, visible_to_employee=component.visible_to_employee, period_scope=component.period_scope, @@ -451,6 +458,18 @@ def build_payroll_period_table(period: PayrollPeriod): raw_source_lines, scheduled_codes=scheduled_component_codes, ) + salary_excluded_only = bool( + statement is None + and work is None + and source_lines + and all( + not components_by_code[line.component_code].include_in_salary + for line in source_lines + ) + ) + if salary_excluded_only: + # A former employee's debt payment is not a new salary or payslip. + rate = None display_source_lines = [ line for line in raw_source_lines @@ -497,6 +516,11 @@ def build_payroll_period_table(period: PayrollPeriod): "deduction_total": str(statement.deduction_total), "net_pay": str(statement.net_pay), "payment_total": str(statement.payment_total), + "non_salary_payment_total": str( + (statement.result_snapshot or {}) + .get("totals", {}) + .get("non_salary_payment_total", "0") + ), "payable": str(statement.payable), } rate_amount = snapshot.get("base_accrual") @@ -587,6 +611,9 @@ def build_payroll_period_table(period: PayrollPeriod): "deduction_total": str(preview_totals.deduction_total), "net_pay": str(preview_totals.net_pay), "payment_total": str(preview_totals.payment_total), + "non_salary_payment_total": str( + preview_totals.non_salary_payment_total + ), "payable": str(preview_totals.payable), } totals_preliminary = True @@ -606,6 +633,8 @@ def build_payroll_period_table(period: PayrollPeriod): status = "ready" if calculation_issue is not None: status = "incomplete" + if salary_excluded_only: + status = "balance_only" projection_target_points = Decimal(str(target_points or "0")) daily_point_value = ( @@ -689,6 +718,7 @@ def build_payroll_period_table(period: PayrollPeriod): return { "period_id": period.pk, + "balance_rules_version": period.balance_rules_version, "period_status": period.status, "currency": period.currency, "calculation_rules": active_rules.to_dict(), diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index e4d2c210..59000359 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -109,6 +109,42 @@ def _hash_items(items: list[str]) -> str: return hashlib.sha256(payload).hexdigest() +def _period_input_hash(period, results): + hashes = [result.input_hash for _, result in results] + if period.balance_rules_version >= 2: + # Balance-only inputs do not produce payslips, but must still invalidate + # an approved calculation when their amounts/settings change. + components = { + component.code: component for component in get_payroll_components(period) + } + outside_lines = ( + PayrollInputLine.objects.filter( + period=period, + status=ApprovalStatus.APPROVED, + ) + .exclude(employee_id__in=[rate.employee_id for rate, _ in results]) + .order_by("pk") + ) + for line in outside_lines: + component = components[line.component_code] + payload = { + "id": line.pk, + "employee_id": line.employee_id, + "component_code": line.component_code, + "amount": str(line.amount), + "include_in_carryover": line.include_in_carryover + and component.include_in_carryover, + "include_in_salary": component.include_in_salary, + "kind": component.kind, + } + hashes.append( + hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + return _hash_items(hashes) + + def _audit( *, actor, @@ -301,12 +337,21 @@ def _calculate_current_period_inputs( ) employee_ids = sorted(work_records) rates = _resolve_rates(period, employee_ids) + component_definitions = { + component.code: component for component in get_payroll_components(period) + } + excluded_salary_codes = [ + code + for code, component in component_definitions.items() + if not component.include_in_salary + ] outside_roster = list( PayrollInputLine.objects.filter( period=period, status=ApprovalStatus.APPROVED, ) .exclude(employee_id__in=employee_ids) + .exclude(component_code__in=excluded_salary_codes) .values_list("employee_id", flat=True) .distinct()[:20] ) @@ -318,10 +363,6 @@ 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 = [] for rate in rates: @@ -384,7 +425,7 @@ def _ensure_run_is_fresh(run: PayrollRun, period: PayrollPeriod) -> None: """Fail closed if approved inputs, rules or persisted snapshots changed.""" _, _, results = _calculate_current_period_inputs(period) - current_input_hash = _hash_items([result.input_hash for _, result in results]) + current_input_hash = _period_input_hash(period, results) current_result_hash = _hash_items([result.result_hash for _, result in results]) first_result = results[0][1] statements = { @@ -487,7 +528,7 @@ def calculate_period( ] or 0 ) + 1 - aggregate_input_hash = _hash_items([result.input_hash for _, result in results]) + aggregate_input_hash = _period_input_hash(period, results) aggregate_result_hash = _hash_items([result.result_hash for _, result in results]) gross_total = sum( (result.totals.gross_after_adjustments for _, result in results), diff --git a/backend/payroll_core/balances.py b/backend/payroll_core/balances.py new file mode 100644 index 00000000..a2146c2f --- /dev/null +++ b/backend/payroll_core/balances.py @@ -0,0 +1,12 @@ +"""Versioned contribution shared by calculation, reconstruction and ledger.""" + +from decimal import Decimal + + +def carryover_contribution(kind: str, amount: Decimal, *, version: int = 2) -> Decimal: + if version not in (1, 2): + raise ValueError("Unsupported payroll balance rules version") + increases = kind in {"adjustment_debit", "deduction"} or ( + kind == "payment" and version == 1 + ) + return amount if increases else -amount diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py index 51e3414e..97630fe6 100644 --- a/backend/payroll_core/calculator.py +++ b/backend/payroll_core/calculator.py @@ -9,6 +9,7 @@ from datetime import date, datetime from decimal import Context, Decimal, DecimalException, localcontext from typing import Protocol +from .balances import carryover_contribution from .domain import ( CalculationWarning, ExpectedTotals, @@ -58,7 +59,7 @@ def _is_finite_decimal(value: object) -> bool: class DeterministicPayrollCalculator: """Pure calculator with explicit Decimal and rounding semantics.""" - CALCULATOR_VERSION = "1.3.0" + CALCULATOR_VERSION = "1.4.0" DECIMAL_PRECISION = 80 MAX_DECIMAL_DIGITS = 30 MAX_FRACTIONAL_PLACES = 12 @@ -158,7 +159,11 @@ class DeterministicPayrollCalculator: ): lines.append(self._to_result_line(input_line, quantize)) - totals = self._calculate_totals(lines, opening_balance=opening_balance) + totals = self._calculate_totals( + lines, + opening_balance=opening_balance, + balance_rules_version=request.balance_rules_version, + ) if totals.payable < 0 and not rules.allow_negative_payable: raise PayrollValidationError( [ @@ -185,6 +190,11 @@ class DeterministicPayrollCalculator: input_hash = _canonical_hash(request.calculation_dict()) rules_payload = rules.to_dict() ruleset_hash = _canonical_hash(rules_payload) + calculator_version = ( + "1.3.0" + if request.balance_rules_version == 1 + else self.CALCULATOR_VERSION + ) result_payload = { "employee_ref": request.employee_ref, "period": request.period.to_dict(), @@ -197,7 +207,7 @@ class DeterministicPayrollCalculator: "ruleset_id": rules.ruleset_id, "ruleset_version": rules.version, "ruleset_hash": ruleset_hash, - "calculator_version": self.CALCULATOR_VERSION, + "calculator_version": calculator_version, "rounding_policy": rules.rounding.value, "input_hash": input_hash, } @@ -219,7 +229,7 @@ class DeterministicPayrollCalculator: ruleset_id=rules.ruleset_id, ruleset_version=rules.version, ruleset_hash=ruleset_hash, - calculator_version=self.CALCULATOR_VERSION, + calculator_version=calculator_version, rounding_policy=rules.rounding.value, input_hash=input_hash, result_hash=result_hash, @@ -262,6 +272,7 @@ class DeterministicPayrollCalculator: is_retro=input_line.is_retro, calculated=False, include_in_carryover=input_line.include_in_carryover, + include_in_salary=input_line.include_in_salary, ) @staticmethod @@ -269,45 +280,59 @@ class DeterministicPayrollCalculator: lines: list[PayrollLine], *, opening_balance: Decimal = Decimal("0"), + balance_rules_version: int = 2, ) -> PayrollTotals: earnings = sum( - (line.amount for line in lines if line.kind is LineKind.EARNING), + ( + line.amount + for line in lines + if line.kind is LineKind.EARNING and line.include_in_salary + ), Decimal("0"), ) adjustment_credits = sum( - (line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_CREDIT), + ( + line.amount + for line in lines + if line.kind is LineKind.ADJUSTMENT_CREDIT and line.include_in_salary + ), Decimal("0"), ) adjustment_debits = sum( - (line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_DEBIT), + ( + line.amount + for line in lines + if line.kind is LineKind.ADJUSTMENT_DEBIT and line.include_in_salary + ), Decimal("0"), ) deductions = sum( - (line.amount for line in lines if line.kind is LineKind.DEDUCTION), + ( + line.amount + for line in lines + if line.kind is LineKind.DEDUCTION and line.include_in_salary + ), Decimal("0"), ) payments = sum( (line.amount for line in lines if line.kind is LineKind.PAYMENT), Decimal("0"), ) - manual_carryover_credits = sum( + non_salary_payments = sum( ( line.amount for line in lines - if line.kind in (LineKind.EARNING, LineKind.ADJUSTMENT_CREDIT) - and not line.calculated - and line.include_in_carryover + if line.kind is LineKind.PAYMENT and not line.include_in_salary ), Decimal("0"), ) - manual_carryover_debits = sum( + carryover_change = sum( ( - line.amount + carryover_contribution( + line.kind.value, line.amount, version=balance_rules_version + ) for line in lines - if line.kind - in (LineKind.ADJUSTMENT_DEBIT, LineKind.DEDUCTION, LineKind.PAYMENT) - and not line.calculated - and line.include_in_carryover + if not line.calculated and line.include_in_carryover ), Decimal("0"), ) @@ -321,10 +346,9 @@ class DeterministicPayrollCalculator: deduction_total=deductions, net_pay=net_pay, payment_total=payments, - payable=net_pay - payments, - closing_balance=( - opening_balance + manual_carryover_debits - manual_carryover_credits - ), + payable=net_pay - payments + non_salary_payments, + non_salary_payment_total=non_salary_payments, + closing_balance=opening_balance + carryover_change, ) @staticmethod @@ -428,6 +452,16 @@ class DeterministicPayrollCalculator: message="Employee reference is required.", ) ) + if type( + request.balance_rules_version + ) is not int or request.balance_rules_version not in (1, 2): + issues.append( + PayrollIssue( + code="INVALID_BALANCE_RULES_VERSION", + field="balance_rules_version", + message="Unsupported carryover rules version.", + ) + ) period_is_valid = self._validate_period(request.period, "period", issues) if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch( request.currency @@ -693,6 +727,15 @@ class DeterministicPayrollCalculator: ) return + for flag in ("include_in_salary", "include_in_carryover"): + if not isinstance(getattr(line, flag), bool): + issues.append( + PayrollIssue( + code="INVALID_COMPONENT_FLAG", + field=f"{prefix}.{flag}", + message="Component participation flags must be boolean.", + ) + ) if not self._is_nonempty_string(line.line_id): issues.append( PayrollIssue( diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py index 2372c231..92e19764 100644 --- a/backend/payroll_core/domain.py +++ b/backend/payroll_core/domain.py @@ -89,6 +89,7 @@ class InputLine: source_period: PayrollPeriod | None = None is_retro: bool = False include_in_carryover: bool = True + include_in_salary: bool = True def to_dict(self) -> dict[str, object]: return { @@ -104,6 +105,7 @@ class InputLine: ), "is_retro": self.is_retro, "include_in_carryover": self.include_in_carryover, + **({"include_in_salary": False} if not self.include_in_salary else {}), } @@ -152,11 +154,17 @@ class PayrollRequest: point_rate_overridden: bool = True lines: tuple[InputLine, ...] = dataclass_field(default_factory=tuple) expected_totals: ExpectedTotals | None = None + balance_rules_version: int = 2 def to_dict(self) -> dict[str, object]: ordered_lines = sorted(self.lines, key=lambda line: line.line_id) return { "employee_ref": self.employee_ref, + **( + {"balance_rules_version": self.balance_rules_version} + if self.balance_rules_version != 1 + else {} + ), "period": self.period.to_dict(), "currency": self.currency, "base_accrual": decimal_text(self.base_accrual), @@ -236,6 +244,7 @@ class PayrollLine: is_retro: bool = False calculated: bool = False include_in_carryover: bool = True + include_in_salary: bool = True def to_dict(self) -> dict[str, object]: return { @@ -252,6 +261,7 @@ class PayrollLine: "is_retro": self.is_retro, "calculated": self.calculated, "include_in_carryover": self.include_in_carryover, + **({"include_in_salary": False} if not self.include_in_salary else {}), } @@ -281,6 +291,11 @@ class PayrollTotals: payment_total: Decimal payable: Decimal closing_balance: Decimal + non_salary_payment_total: Decimal = Decimal("0") + + @property + def salary_payment_total(self): + return self.payment_total - self.non_salary_payment_total @property def recalculated_gross(self) -> Decimal: @@ -299,6 +314,15 @@ class PayrollTotals: "payment_total": decimal_text(self.payment_total), "payable": decimal_text(self.payable), "closing_balance": decimal_text(self.closing_balance), + **( + { + "non_salary_payment_total": decimal_text( + self.non_salary_payment_total + ) + } + if self.non_salary_payment_total + else {} + ), } diff --git a/backend/payroll_core/tests/test_salary_participation.py b/backend/payroll_core/tests/test_salary_participation.py new file mode 100644 index 00000000..62ab67a6 --- /dev/null +++ b/backend/payroll_core/tests/test_salary_participation.py @@ -0,0 +1,107 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from payroll_core import ( + DeterministicPayrollCalculator, + InputLine, + LineKind, + PayrollPeriod, + PayrollRequest, + PayrollRules, +) + + +def request(*, kind=LineKind.PAYMENT, salary=True, carryover=True, version=2): + return PayrollRequest( + employee_ref="employee:42", + period=PayrollPeriod(date(2026, 8, 1), date(2026, 8, 31)), + currency="RUB", + base_accrual=Decimal("80000"), + base_source_ref="rate:42", + opening_balance=Decimal("5000"), + balance_rules_version=version, + lines=( + InputLine( + line_id="pay:1", + code="ADVANCE", + label="Аванс", + kind=kind, + amount=Decimal("10000"), + source_ref="fixture:1", + include_in_salary=salary, + include_in_carryover=carryover, + ), + ), + ) + + +RULES = PayrollRules( + ruleset_id="standard", version="1", effective_from=date(2026, 1, 1) +) + + +@pytest.mark.parametrize( + "kind,sign", + [ + (LineKind.EARNING, -1), + (LineKind.ADJUSTMENT_CREDIT, -1), + (LineKind.ADJUSTMENT_DEBIT, 1), + (LineKind.DEDUCTION, 1), + (LineKind.PAYMENT, -1), + ], +) +@pytest.mark.parametrize("carryover", [True, False]) +def test_salary_exclusion_is_independent_of_carryover(kind, sign, carryover): + result = DeterministicPayrollCalculator().calculate( + request(kind=kind, salary=False, carryover=carryover), RULES + ) + totals = result.totals + assert ( + totals.gross_before_adjustments + == totals.gross_after_adjustments + == totals.net_pay + == totals.payable + == Decimal("80000") + ) + assert totals.adjustment_total == totals.deduction_total == 0 + assert totals.payment_total == (Decimal("10000") if kind == LineKind.PAYMENT else 0) + assert totals.non_salary_payment_total == totals.payment_total + assert totals.closing_balance == Decimal("5000") + ( + Decimal("10000") * sign if carryover else 0 + ) + assert result.lines[-1].include_in_salary is False + + +def test_salary_payment_still_subtracts_from_payable(): + result = DeterministicPayrollCalculator().calculate(request(), RULES) + assert result.totals.payable == Decimal("70000") + assert result.totals.payment_total == Decimal("10000") + assert result.totals.non_salary_payment_total == 0 + assert result.totals.closing_balance == Decimal("-5000") + + +def test_old_request_and_result_hashes_are_byte_compatible(): + result = DeterministicPayrollCalculator().calculate(request(version=1), RULES) + # Golden hashes produced by the unmodified 1.3.0 calculator. + assert ( + result.input_hash + == "d398df4ebbed1f5469479dceea0a9a9fbb8dff00b57b1debab2256282ec22d06" + ) + assert ( + result.result_hash + == "ead44fd4cac168734c24e58de20f654e2c0406d4862ef3d9aaad68b800320b27" + ) + assert result.calculator_version == "1.3.0" + assert result.totals.closing_balance == Decimal("15000") + + +@pytest.mark.parametrize("kind", list(LineKind)) +def test_only_payment_changes_carryover_direction(kind): + calculator = DeterministicPayrollCalculator() + old = calculator.calculate(request(kind=kind, version=1), RULES) + current = calculator.calculate(request(kind=kind), RULES) + assert current.totals.payable == old.totals.payable + difference = Decimal("-20000") if kind == LineKind.PAYMENT else Decimal("0") + assert current.totals.closing_balance - old.totals.closing_balance == difference diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 1776b383..3d8caa69 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -209,6 +209,7 @@ def test_manager_can_customize_system_component_behavior( "period_scope": "current_and_future", "scope_period_id": period.pk, "include_in_point_base": False, + "include_in_salary": True, "include_in_carryover": True, "visible_to_employee": False, "is_custom": False, diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index 1b5d7fcd..c45a1fee 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -644,7 +644,7 @@ def test_closing_period_freezes_component_configuration( closed_components = { component.code: component for component in get_payroll_components(june_period) } - assert june_period.component_snapshot["version"] == 1 + assert june_period.component_snapshot["version"] == 2 assert closed_components["BONUS"].name == "Премия до закрытия" assert closed_components["BONUS"].highlight_color == "green" assert closed_components[custom.code].name == "Историческая колонка" diff --git a/backend/tests/finance/test_salary_participation.py b/backend/tests/finance/test_salary_participation.py new file mode 100644 index 00000000..ec4a8312 --- /dev/null +++ b/backend/tests/finance/test_salary_participation.py @@ -0,0 +1,283 @@ +import calendar +from datetime import date +from decimal import Decimal +from importlib import import_module + +import pytest +from django.apps import apps +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils import timezone + +from api.v1.finance.admin.workspace_payload import _period_input_state +from api.v1.finance.serializers import OwnPayrollStatementSerializer +from finance.models import ( + PayrollCustomComponent, + PayrollInputLine, + PayrollPeriod, +) +from finance.payroll.balances import opening_balances +from finance.payroll.components import ( + build_payroll_component_snapshot, + get_payroll_components, +) +from finance.payroll.exceptions import PayrollOperationError +from finance.payroll.services import ( + _ensure_run_is_fresh, + calculate_period, +) +from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(user_factory, auth_client_factory): + manager, employee, checker = user_factory(), user_factory(), user_factory() + grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll") + periods = {} + for month in (8, 9, 10, 11): + periods[month] = PayrollPeriod.objects.create( + code=f"2026-{month:02}", + date_from=date(2026, month, 1), + date_to=date(2026, month, calendar.monthrange(2026, month)[1]), + created_by=manager, + status="closed" if month < 10 else "open", + balance_rules_version=1 if month < 10 else 2, + ) + component = PayrollCustomComponent.objects.create( + name="Выплата долга", + kind="payment", + include_in_salary=False, + include_in_carryover=True, + period_scope="all", + created_by=manager, + ) + return manager, employee, checker, periods, component, auth_client_factory(manager) + + +def line(setup, period, **overrides): + manager, employee, _, _, component, _ = setup + return PayrollInputLine.objects.create( + period=period, + employee=employee, + component_code=component.code, + amount="5000", + status="approved", + created_by=manager, + approved_by=setup[2], + approved_at=timezone.now(), + **overrides, + ) + + +def test_previous_period_payments_keep_legacy_sign_and_current_payments_reduce_debt( + setup, +): + _, employee, _, periods, component, client = setup + for month in (8, 9): + # An old v1 snapshot contains no salary-participation flag. + snapshot = build_payroll_component_snapshot(periods[month]) + snapshot["version"] = 1 + for payload in snapshot["components"]: + payload.pop("include_in_salary") + PayrollPeriod.objects.filter(pk=periods[month].pk).update( + component_snapshot=snapshot + ) + line(setup, periods[month]) + line(setup, periods[10]) + assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal("10000") + assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal("5000") + ledger = client.get( + reverse( + "api:v1:finance-payroll:admin-period-carryovers", + kwargs={"pk": periods[10].pk}, + ) + ) + assert ledger.status_code == 200, ledger.content + contributions = { + entry["period"]["id"]: Decimal(entry["contribution"]) + for entry in ledger.json()["entries"] + } + assert contributions == { + periods[8].pk: Decimal("5000"), + periods[9].pk: Decimal("5000"), + periods[10].pk: Decimal("-5000"), + } + for month in (8, 9): + periods[month].refresh_from_db() + assert ( + get_payroll_components(periods[month]) + .get(code=component.code) + .include_in_salary + is True + ) + + +def test_excluded_payment_does_not_reduce_current_salary_or_point_base(setup): + manager, employee, checker, periods, component, _ = setup + make_approved_period_inputs( + period=periods[10], employee=employee, creator=manager, approver=checker + ) + component.include_in_point_base = ( + True # Defensive adapter protection, even against bad ORM data. + ) + component.save(update_fields=["include_in_point_base"]) + line(setup, periods[10]) + run = calculate_period(periods[10].pk, actor=manager) + statement = run.statements.get(employee=employee) + assert statement.payable == Decimal("80000") + assert statement.gross_total == Decimal("80000") + assert statement.payment_total == Decimal("5000") + assert statement.closing_balance == Decimal("-5000") + assert statement.input_snapshot["point_base_accrual"] == "80000" + assert statement.result_snapshot["totals"]["non_salary_payment_total"] == "5000" + assert statement.input_snapshot["lines"][0]["include_in_salary"] is False + detail = OwnPayrollStatementSerializer(statement).data + assert Decimal(detail["non_salary_payment_total"]) == Decimal("5000") + assert ( + next(item for item in detail["lines"] if item["code"] == component.code)[ + "include_in_salary" + ] + is False + ) + + +def test_inactive_employee_payment_without_work_does_not_block_or_generate_payslip( + setup, user_factory +): + manager, employee, checker, periods, component, client = setup + make_approved_period_inputs( + period=periods[10], employee=employee, creator=manager, approver=checker + ) + former = user_factory(active=False) + PayrollInputLine.objects.create( + period=periods[10], + employee=former, + component_code=component.code, + amount="3500", + status="approved", + created_by=manager, + approved_by=checker, + approved_at=timezone.now(), + ) + state = _period_input_state(periods[10]) + assert state.approved_input_outside_roster_employee_ids == [] + run = calculate_period(periods[10].pk, actor=manager) + assert not run.statements.filter(employee=former).exists() + table = client.get( + reverse( + "api:v1:finance-payroll:admin-period-table", kwargs={"pk": periods[10].pk} + ) + ).json() + row = next(item for item in table["rows"] if item["employee"]["id"] == former.pk) + assert row["status"] == "balance_only" + assert Decimal(row["payment_total"]) == Decimal("3500") + assert Decimal(row["payable"]) == 0 + assert Decimal(row["closing_balance"]) == Decimal("-3500") + assert Decimal(table["recorded_payments"]["total_recorded"]) == Decimal("3500") + _ensure_run_is_fresh(run, periods[10]) + PayrollInputLine.objects.create( + period=periods[10], + employee=former, + component_code=component.code, + amount="100", + status="approved", + created_by=manager, + approved_by=checker, + approved_at=timezone.now(), + ) + with pytest.raises(PayrollOperationError) as error: + _ensure_run_is_fresh(run, periods[10]) + assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED" + + +@pytest.mark.parametrize("builtin", [False, True]) +def test_component_setting_is_saved_and_point_base_is_cleared(setup, builtin): + _, _, _, periods, component, client = setup + code = "BONUS" if builtin else component.code + payload = { + "name": "Отдельная выплата" if builtin else component.name, + "kind": "payment", + "period_scope": "current_and_future", + "period_id": periods[10].pk, + "requires_reason": False, + "include_in_salary": False, + "include_in_carryover": True, + "include_in_point_base": True, + "visible_to_employee": False, + } + url = reverse( + "api:v1:finance-payroll:admin-component-detail", kwargs={"code": code} + ) + response = client.patch(url, payload, format="json") + assert response.status_code == 200, response.content + assert response.json()["include_in_salary"] is False + assert response.json()["include_in_point_base"] is False + payload.pop("include_in_salary") + response = client.patch(url, payload, format="json") + assert response.status_code == 200, response.content + assert response.json()["include_in_salary"] is False + assert response.json()["include_in_point_base"] is False + + +def test_migration_preserves_old_snapshots_and_only_activates_new_open_periods(setup): + _, _, _, periods, _, _ = setup + old_snapshot = {"version": 1, "components": []} + PayrollPeriod.objects.filter(pk=periods[8].pk).update( + component_snapshot=old_snapshot + ) + PayrollPeriod.objects.all().update(balance_rules_version=1) + migration = import_module( + "finance.migrations.0041_salary_participation_and_balance_rules" + ) + migration.activate_current_rules(apps, None) + for month, period in periods.items(): + period.refresh_from_db() + assert period.balance_rules_version == (1 if month < 10 else 2) + assert periods[8].component_snapshot == old_snapshot + september = periods[9] + assert september.component_snapshot["version"] == 1 + assert all( + component.include_in_salary for component in get_payroll_components(september) + ) + assert not periods[10].component_snapshot + with pytest.raises(ValidationError): + september.balance_rules_version = 2 + september.save(update_fields=["balance_rules_version"]) + + +def test_migration_keeps_existing_unclosed_run_fresh_and_freezes_its_settings(setup): + manager, employee, checker, periods, component, _ = setup + PayrollPeriod.objects.all().update(balance_rules_version=1) + current = PayrollPeriod.objects.get(pk=periods[10].pk) + component.include_in_salary = True + component.save(update_fields=["include_in_salary"]) + make_approved_period_inputs( + period=current, employee=employee, creator=manager, approver=checker + ) + line(setup, current) + run = calculate_period(current.pk, actor=manager) + previous = list( + run.statements.values( + "input_snapshot", "result_snapshot", "input_hash", "result_hash" + ) + ) + import_module( + "finance.migrations.0041_salary_participation_and_balance_rules" + ).activate_current_rules(apps, None) + current.refresh_from_db() + periods[11].refresh_from_db() + assert current.balance_rules_version == 1 + assert periods[11].balance_rules_version == 2 + component.include_in_salary = False + component.save(update_fields=["include_in_salary"]) + _ensure_run_is_fresh(run, current) + assert ( + list( + run.statements.values( + "input_snapshot", "result_snapshot", "input_hash", "result_hash" + ) + ) + == previous + ) diff --git a/frontend/src/components/finance/PayrollStatementDetailModal.tsx b/frontend/src/components/finance/PayrollStatementDetailModal.tsx index f7e84f16..c99645b1 100644 --- a/frontend/src/components/finance/PayrollStatementDetailModal.tsx +++ b/frontend/src/components/finance/PayrollStatementDetailModal.tsx @@ -330,6 +330,16 @@ export function PayrollStatementDetailModal({

+ {Number(detail.non_salary_payment_total || 0) > 0 ? ( +

+ В том числе вне расчёта зарплаты:{" "} + {formatPayrollMoney( + detail.non_salary_payment_total!, + detail.currency, + )} + . Эта выплата не вычитается из зарплаты текущего периода. +

+ ) : null}
@@ -415,6 +425,11 @@ export function PayrollStatementDetailModal({ Перерасчёт ) : null} + {line.include_in_salary === false ? ( + + Вне расчёта зарплаты + + ) : null} {sourcePeriod ? (

diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index c49f1aad..937c1644 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -80,6 +80,10 @@ const statusMeta: Record< ready: { label: "Готово", className: "app-selected" }, draft: { label: "Есть черновики", className: "app-feedback-warning" }, incomplete: { label: "Не заполнено", className: "app-feedback-danger" }, + balance_only: { + label: "Вне расчёта зарплаты", + className: "app-feedback-info", + }, }; const kindLabels = { @@ -723,6 +727,7 @@ function PayrollComponentSettingsModal({ rows, components, currency, + balanceRulesVersion, onSave, onDelete, onClose, @@ -731,6 +736,7 @@ function PayrollComponentSettingsModal({ rows: PayrollPeriodTableRow[]; components: PayrollPeriodTableComponent[]; currency: string; + balanceRulesVersion?: number; onSave: (payload: PayrollCustomComponentWrite) => Promise; onDelete?: (mode: "current" | "current_and_future") => Promise; onClose: () => void; @@ -740,6 +746,9 @@ function PayrollComponentSettingsModal({ component?.kind || "earning", ); const requiresReason = component?.requires_reason || false; + const [includeInSalary, setIncludeInSalary] = useState( + component?.include_in_salary ?? true, + ); const [includeInPointBase, setIncludeInPointBase] = useState( component?.include_in_point_base || false, ); @@ -814,7 +823,9 @@ function PayrollComponentSettingsModal({ name: normalizedName, kind, requires_reason: requiresReason, - include_in_point_base: isScheduled ? false : includeInPointBase, + include_in_salary: !isScheduled && includeInSalary, + include_in_point_base: + !isScheduled && includeInSalary && includeInPointBase, include_in_carryover: isScheduled ? false : includeInCarryover, visible_to_employee: isScheduled ? false : visibleToEmployee, highlight_color: highlightColor, @@ -858,7 +869,9 @@ function PayrollComponentSettingsModal({ kind, display_order: component?.display_order || 0, requires_reason: requiresReason, - include_in_point_base: isScheduled ? false : includeInPointBase, + include_in_salary: !isScheduled && includeInSalary, + include_in_point_base: + !isScheduled && includeInSalary && includeInPointBase, include_in_carryover: isScheduled ? false : includeInCarryover, visible_to_employee: isScheduled ? false : visibleToEmployee, period_scope: isScheduled ? "current_and_future" : periodScope, @@ -870,11 +883,13 @@ function PayrollComponentSettingsModal({ const calculationPreview = componentCalculationHelp( previewComponent, components, + balanceRulesVersion, ); const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, { code: component?.code || "PREVIEW", kind, include_in_point_base: includeInPointBase, + include_in_salary: !isScheduled && includeInSalary, }); if (typeof document === "undefined") return null; @@ -1068,6 +1083,28 @@ function PayrollComponentSettingsModal({ автоматически не копируются. +