diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 30035a0b..aa0d75f2 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -951,7 +951,7 @@ COMMUNICATIONS_PARTICIPANT_RESOLVER = "employees.utils.resolve_chat_participants # version after changing calculation semantics. FINANCE_PAYROLL = { "RULESET_ID": os.getenv("PAYROLL_RULESET_ID", "eusrr-standard"), - "RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.08.1"), + "RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.09.1"), "EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"), "EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None, # Excel-compatible point basis is base accrual plus bonus inputs. Below the diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py index 70312bcf..ea4c31c7 100644 --- a/backend/finance/payroll/adapter.py +++ b/backend/finance/payroll/adapter.py @@ -19,7 +19,10 @@ from finance.models import ( PayrollWorkRecord, ) -POINT_BASE_COMPONENT_CODES = frozenset({"BONUS"}) +POINT_BASE_COMPONENT_CODES = frozenset( + {"BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT"} +) +POINT_BASE_DEBIT_COMPONENT_CODES = frozenset({"CORRECTION_DEBIT"}) def _core_period(period: PayrollPeriod) -> CorePayrollPeriod: @@ -92,12 +95,19 @@ def _point_base_accrual( rate: EmployeePayRate | None, input_lines: list[PayrollInputLine], ) -> Decimal: - """Reproduce the Excel point basis: base salary plus bonus inputs.""" + """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. + """ base_amount = rate.amount if rate is not None else Decimal("0") return base_amount + sum( ( - record.amount + -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 ), diff --git a/backend/finance/payroll/config.py b/backend/finance/payroll/config.py index 8ebb057a..76a09f2d 100644 --- a/backend/finance/payroll/config.py +++ b/backend/finance/payroll/config.py @@ -13,7 +13,7 @@ from payroll_core import PayrollRules, PointPolicy, RoundingPolicy DEFAULT_CONFIG = { "RULESET_ID": "eusrr-standard", - "RULESET_VERSION": "2026.08.1", + "RULESET_VERSION": "2026.09.1", "EFFECTIVE_FROM": "2026-01-01", "EFFECTIVE_TO": None, "POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value, diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 382c5cb1..03077034 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -31,7 +31,11 @@ from finance.models import ( ) from finance.payroll.components import get_payroll_components -from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request +from .adapter import ( + POINT_BASE_COMPONENT_CODES, + POINT_BASE_DEBIT_COMPONENT_CODES, + build_core_preview_request, +) from .balances import opening_balances from .attendance import ( calculate_attendance_day_points, @@ -76,7 +80,9 @@ def _point_base_amount(rate_amount, component_amounts): return None return Decimal(str(rate_amount)) + sum( ( - Decimal(str(component_amounts.get(code, "0"))) + -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"), diff --git a/backend/requests_app/application/vacation.py b/backend/requests_app/application/vacation.py index f47c743b..58cce2b0 100644 --- a/backend/requests_app/application/vacation.py +++ b/backend/requests_app/application/vacation.py @@ -4,6 +4,7 @@ from decimal import Decimal, ROUND_HALF_UP from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction +from django.db.models import F from rest_framework.exceptions import ValidationError from finance.enums import ( @@ -15,6 +16,7 @@ from finance.models import ( EmployeePayRate, PayrollInputLine, PayrollPeriod, + PayrollStatement, VacationPayInstruction, ) from finance.payroll.config import base_rate_code @@ -31,6 +33,8 @@ from requests_app.models import Request, RequestVacationTerms MONEY_QUANTUM = Decimal("0.01") +VACATION_AVERAGE_PERIOD_LIMIT = 3 +VACATION_AVERAGE_EXCLUDED_CODES = frozenset({"VACATION_PAY", "ONE_TIME_PAYMENT"}) def vacation_total_days(request_obj: Request) -> int: @@ -85,11 +89,66 @@ def _approved_rate_for_vacation(request_obj: Request) -> EmployeePayRate: return rate +def _historical_average_daily_income( + request_obj: Request, + *, + currency: str, +) -> tuple[Decimal, int] | None: + """Average eligible daily income over up to three available past periods.""" + + statements = list( + PayrollStatement.objects.filter( + employee_id=request_obj.employee_id, + currency=currency, + run__period__date_to__lt=request_obj.date_from, + run__period__current_run_id=F("run_id"), + ) + .select_related("run__period") + .prefetch_related("lines") + .order_by("-run__period__date_to", "-run__period__date_from", "-id")[ + :VACATION_AVERAGE_PERIOD_LIMIT + ] + ) + if not statements: + return None + + schedule, _ = resolve_employee_schedule(request_obj.employee) + income_total = Decimal("0") + workday_total = 0 + period_count = 0 + for statement in statements: + workdays = count_period_workdays(statement.run.period, schedule) + excluded_income = sum( + ( + line.amount + for line in statement.lines.all() + if line.code in VACATION_AVERAGE_EXCLUDED_CODES + ), + Decimal("0"), + ) + eligible_income = statement.gross_total - excluded_income + if workdays < 1 or eligible_income <= 0: + continue + income_total += eligible_income + workday_total += workdays + period_count += 1 + + if period_count == 0 or workday_total == 0: + return None + return ( + (income_total / Decimal(workday_total)).quantize( + MONEY_QUANTUM, + rounding=ROUND_HALF_UP, + ), + period_count, + ) + + def _calculate_vacation_pay( request_obj: Request, *, paid_days: int, -) -> tuple[PayrollPeriod, Decimal, Decimal]: +) -> tuple[PayrollPeriod, Decimal, Decimal, int]: period = _matching_payroll_period(request_obj) if period is None: raise ValidationError( @@ -99,30 +158,39 @@ def _calculate_vacation_pay( ) } ) - rate = _approved_rate_for_vacation(request_obj) - if rate.currency != period.currency: - raise ValidationError( - { - "record_to_finance": ( - "Валюта ставки сотрудника не совпадает с валютой расчётного периода." - ) - } - ) - schedule, _ = resolve_employee_schedule(request_obj.employee) - workdays = count_period_workdays(period, schedule) - if workdays < 1: - raise ValidationError( - {"record_to_finance": "В расчётном периоде нет плановых рабочих дней."} - ) - daily_rate = (rate.amount / Decimal(workdays)).quantize( - MONEY_QUANTUM, - rounding=ROUND_HALF_UP, + historical_average = _historical_average_daily_income( + request_obj, + currency=period.currency, ) + average_period_count = 0 + if historical_average is not None: + daily_rate, average_period_count = historical_average + else: + rate = _approved_rate_for_vacation(request_obj) + if rate.currency != period.currency: + raise ValidationError( + { + "record_to_finance": ( + "Валюта ставки сотрудника не совпадает с валютой " + "расчётного периода." + ) + } + ) + schedule, _ = resolve_employee_schedule(request_obj.employee) + workdays = count_period_workdays(period, schedule) + if workdays < 1: + raise ValidationError( + {"record_to_finance": "В расчётном периоде нет плановых рабочих дней."} + ) + daily_rate = (rate.amount / Decimal(workdays)).quantize( + MONEY_QUANTUM, + rounding=ROUND_HALF_UP, + ) amount = (daily_rate * paid_days).quantize( MONEY_QUANTUM, rounding=ROUND_HALF_UP, ) - return period, daily_rate, amount + return period, daily_rate, amount, average_period_count def _vacation_line_reason( @@ -130,9 +198,16 @@ def _vacation_line_reason( *, paid_days: int, daily_rate: Decimal, + average_period_count: int, ) -> str: + basis = ( + f"среднее за {average_period_count} расч. мес." + if average_period_count + else "текущая ставка; история отсутствует" + ) return ( - f"Отпуск по заявлению #{request_obj.pk}: {paid_days} дн. × {daily_rate:.2f} ₽" + f"Отпуск по заявлению #{request_obj.pk}: {paid_days} дн. × " + f"{daily_rate:.2f} ₽ ({basis})" ) @@ -145,6 +220,7 @@ def _upsert_payroll_input_line( paid_days: int, daily_rate: Decimal, amount: Decimal, + average_period_count: int, ) -> PayrollInputLine: line = instruction.payroll_input_line if line is None: @@ -162,6 +238,7 @@ def _upsert_payroll_input_line( request_obj, paid_days=paid_days, daily_rate=daily_rate, + average_period_count=average_period_count, ) try: line.full_clean() @@ -291,7 +368,7 @@ def save_vacation_terms( ) if record_to_finance and not instruction_is_posted: - period, daily_rate, amount = _calculate_vacation_pay( + period, daily_rate, amount, average_period_count = _calculate_vacation_pay( locked_request, paid_days=paid_days, ) @@ -320,6 +397,7 @@ def save_vacation_terms( paid_days=paid_days, daily_rate=daily_rate, amount=amount, + average_period_count=average_period_count, ) instruction.payroll_input_line = line instruction.save( diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 8b010516..1cf3d7fe 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -19,6 +19,9 @@ from finance.models import ( EmployeePayRate, PayrollInputLine, PayrollPeriod, + PayrollRun, + PayrollStatement, + PayrollStatementLine, VacationPayInstruction, ) from tasks.models import ( @@ -1353,6 +1356,124 @@ def test_approving_vacation_requests_terms_and_can_save_finance_instruction( assert "7 дн. × 3809.52 ₽" in line.reason +@pytest.mark.parametrize( + ("available_periods", "expected_amount"), + ( + (1, Decimal("7000.00")), + (2, Decimal("10581.41")), + (3, Decimal("14212.10")), + ), +) +def test_vacation_pay_uses_available_payroll_periods_up_to_three( + auth_client, + make_user, + make_request, + available_periods, + expected_amount, +) -> None: + owner = make_user(email="vacation-average-owner@example.com") + recipient = make_user(email="vacation-average-approver@example.com") + vacation = make_request( + employee=owner, + type_=RequestType.VACATION, + status=RequestStatus.PENDING, + ) + vacation.date_from = date(2026, 8, 10) + vacation.date_to = date(2026, 8, 16) + vacation.save(update_fields=["date_from", "date_to"]) + vacation.recipients.add(recipient) + PayrollPeriod.objects.create( + code="2026-08-vacation-average", + date_from=date(2026, 8, 1), + date_to=date(2026, 8, 31), + ) + + historical = ( + ( + "2026-05-vacation-average", + date(2026, 5, 1), + date(2026, 5, 31), + Decimal("21000"), + ), + ( + "2026-06-vacation-average", + date(2026, 6, 1), + date(2026, 6, 30), + Decimal("44000"), + ), + ( + "2026-07-vacation-average", + date(2026, 7, 1), + date(2026, 7, 31), + Decimal("79000"), + ), + ) + for index, (code, date_from, date_to, gross_total) in enumerate( + historical[:available_periods], + start=1, + ): + period = PayrollPeriod.objects.create( + code=code, + date_from=date_from, + date_to=date_to, + ) + run = PayrollRun.objects.create( + period=period, + revision=1, + ruleset_id="vacation-average-test", + ruleset_version="1", + ruleset_hash=f"rules-{index}", + calculator_version="1", + input_hash=f"input-{index}", + result_hash=f"result-{index}", + employee_count=1, + requested_by=recipient, + ) + period.current_run = run + period.save(update_fields=["current_run"]) + statement = PayrollStatement.objects.create( + run=run, + employee=owner, + employee_snapshot={}, + currency="RUB", + gross_before_adjustments=gross_total, + adjustment_total=Decimal("0"), + gross_total=gross_total, + deduction_total=Decimal("0"), + net_pay=gross_total, + payment_total=Decimal("0"), + payable=gross_total, + input_hash=f"statement-input-{index}", + result_hash=f"statement-result-{index}", + ) + if code.startswith("2026-07"): + PayrollStatementLine.objects.create( + statement=statement, + position=1, + line_id="vacation-pay:test", + code="VACATION_PAY", + label="Отпускные", + kind="earning", + amount=Decimal("10000"), + source_ref="test:vacation-pay", + ) + + auth_client(recipient).post(f"{API_BASE}{vacation.id}/approve/") + response = auth_client(recipient).put( + f"{API_BASE}{vacation.id}/vacation-terms/", + data={"paid_days": 7, "record_to_finance": True}, + format="json", + ) + + assert response.status_code == 200, response.content + instruction = VacationPayInstruction.objects.get(vacation_terms__request=vacation) + assert instruction.amount == expected_amount + assert ( + f"среднее за {available_periods} расч. мес." + in instruction.payroll_input_line.reason + ) + + def test_unpaid_vacation_can_skip_finance_and_updates_exact_statistics( auth_client, make_user, make_request ) -> None: diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py index 2315e510..a61eef09 100644 --- a/backend/tests/finance/test_payroll_services.py +++ b/backend/tests/finance/test_payroll_services.py @@ -436,6 +436,66 @@ def test_calculation_uses_excel_point_basis_with_bonus( assert statement.result_snapshot["warnings"] == [] +def test_calculation_applies_corrections_before_point_recalculation( + june_period, + payroll_users, +): + operator = payroll_users["operator"] + approver = payroll_users["input_approver"] + employee = payroll_users["employee"] + rate = EmployeePayRate.objects.create( + employee=employee, + amount=Decimal("80000"), + point_rate=None, + currency="RUB", + effective_from=date(2026, 1, 1), + created_by=operator, + ) + work = PayrollWorkRecord.objects.create( + period=june_period, + employee=employee, + target_points=Decimal("105"), + actual_points=Decimal("95"), + created_by=operator, + ) + bonus = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=PayrollComponent.objects.get(code="BONUS"), + amount=Decimal("10000"), + created_by=operator, + ) + correction = PayrollInputLine.objects.create( + period=june_period, + employee=employee, + component=PayrollComponent.objects.get(code="CORRECTION_DEBIT"), + amount=Decimal("20000"), + reason="Корректировка базы выработки", + created_by=operator, + ) + for instance, approve in ( + (rate, approve_pay_rate), + (work, approve_work_record), + (bonus, approve_input_line), + (correction, approve_input_line), + ): + approve( + instance.pk, + actor=approver, + expected_lock_version=instance.lock_version, + ) + + 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.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") + + def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge( june_period, payroll_users, @@ -479,6 +539,28 @@ def test_full_workflow_requires_a_second_person_and_employee_can_acknowledge( assert PayrollStatementAcknowledgement.objects.count() == 1 +def test_closed_period_cannot_be_recalculated_or_change_its_statement( + june_period, + payroll_users, +): + run = calculate_excel_period(june_period, payroll_users) + publish_calculation(run, payroll_users) + close_period(june_period.pk, actor=payroll_users["publisher"]) + statement = run.statements.get(employee=payroll_users["employee"]) + original_result_hash = statement.result_hash + original_gross_total = statement.gross_total + original_run_count = PayrollRun.objects.filter(period=june_period).count() + + with pytest.raises(PayrollOperationError) as error: + calculate_period(june_period.pk, actor=payroll_users["operator"]) + + assert error.value.code == "PERIOD_CLOSED" + statement.refresh_from_db() + assert statement.result_hash == original_result_hash + assert statement.gross_total == original_gross_total + assert PayrollRun.objects.filter(period=june_period).count() == original_run_count + + def test_override_permission_never_replaces_operational_approval_permissions( user_factory, ): diff --git a/frontend/src/components/requests/RequestVacationTermsModal.tsx b/frontend/src/components/requests/RequestVacationTermsModal.tsx index fe056310..b4d7f856 100644 --- a/frontend/src/components/requests/RequestVacationTermsModal.tsx +++ b/frontend/src/components/requests/RequestVacationTermsModal.tsx @@ -246,7 +246,7 @@ export function RequestVacationTermsModal({ request, onClose, onSaved }: Props) {financeInstruction?.amount ? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.` - : `Создать начисление отпускных за ${paidDays} дн. по дневной ставке сотрудника.`} + : `Создать начисление отпускных за ${paidDays} дн. по среднему дневному заработку, рассчитанному по данным не более трёх последних доступных периодов.`}