Use historical earnings for vacation pay

This commit is contained in:
lizerk 2026-09-03 17:19:35 +03:00
parent 0627da80f6
commit f4795922fe
8 changed files with 327 additions and 30 deletions

View File

@ -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

View File

@ -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
),

View File

@ -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,

View File

@ -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"),

View File

@ -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(

View File

@ -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:

View File

@ -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,
):

View File

@ -246,7 +246,7 @@ export function RequestVacationTermsModal({ request, onClose, onSaved }: Props)
<span className="app-text-muted mt-1 block text-xs leading-relaxed">
{financeInstruction?.amount
? `Начисление ${financeInstruction.amount} ₽: ${paidDays} дн. × ${financeInstruction.daily_rate} ₽.`
: `Создать начисление отпускных за ${paidDays} дн. по дневной ставке сотрудника.`}
: `Создать начисление отпускных за ${paidDays} дн. по среднему дневному заработку, рассчитанному по данным не более трёх последних доступных периодов.`}
</span>
</span>
<Banknote size={17} className="app-text-muted ml-auto mt-1 shrink-0" />