Merge branch 'develop'
This commit is contained in:
commit
87557e27d3
@ -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
|
||||
|
||||
@ -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
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
):
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user