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} дн. по среднему дневному заработку, рассчитанному по данным не более трёх последних доступных периодов.`}