188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
"""Mapping between approved Django records and payroll-core DTOs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Context, Decimal, ROUND_HALF_UP, localcontext
|
|
|
|
from payroll_core import (
|
|
ExpectedTotals,
|
|
InputLine,
|
|
LineKind,
|
|
PayrollPeriod as CorePayrollPeriod,
|
|
PayrollRequest,
|
|
)
|
|
|
|
from finance.models import (
|
|
EmployeePayRate,
|
|
PayrollInputLine,
|
|
PayrollPeriod,
|
|
PayrollWorkRecord,
|
|
)
|
|
|
|
POINT_BASE_COMPONENT_CODES = frozenset({"BONUS"})
|
|
|
|
|
|
def _core_period(period: PayrollPeriod) -> CorePayrollPeriod:
|
|
return CorePayrollPeriod(start=period.date_from, end=period.date_to)
|
|
|
|
|
|
def _expected_totals(work_record: PayrollWorkRecord) -> ExpectedTotals | None:
|
|
values = (
|
|
work_record.expected_point_amount,
|
|
work_record.expected_gross,
|
|
work_record.expected_recalculated_gross,
|
|
work_record.expected_payable,
|
|
)
|
|
if all(value is None for value in values):
|
|
return None
|
|
return ExpectedTotals(
|
|
point_amount=work_record.expected_point_amount,
|
|
gross=work_record.expected_gross,
|
|
recalculated_gross=work_record.expected_recalculated_gross,
|
|
payable=work_record.expected_payable,
|
|
)
|
|
|
|
|
|
def _mapped_input_lines(input_lines: list[PayrollInputLine]) -> tuple[InputLine, ...]:
|
|
mapped_lines = []
|
|
for record in input_lines:
|
|
source_period = None
|
|
if record.relates_to_period_id:
|
|
source_period = _core_period(record.relates_to_period)
|
|
mapped_lines.append(
|
|
InputLine(
|
|
line_id=f"input:{record.pk}",
|
|
code=record.component.code,
|
|
label=record.component.name,
|
|
kind=LineKind(record.component.kind),
|
|
amount=record.amount,
|
|
source_ref=(
|
|
record.source_ref or f"{record.source}:payroll-input:{record.pk}"
|
|
),
|
|
reason=record.reason,
|
|
source_period=source_period,
|
|
is_retro=source_period is not None,
|
|
)
|
|
)
|
|
return tuple(mapped_lines)
|
|
|
|
|
|
def _effective_point_rate(
|
|
rate: EmployeePayRate | None,
|
|
point_base_accrual: Decimal,
|
|
target_points: Decimal | None,
|
|
*,
|
|
rounding: str = ROUND_HALF_UP,
|
|
) -> Decimal:
|
|
"""Resolve a nullable excess price without persisting the derived value."""
|
|
|
|
if rate is not None and rate.point_rate is not None:
|
|
return rate.point_rate
|
|
if target_points is None or target_points <= 0:
|
|
return Decimal("0")
|
|
with localcontext(Context(prec=80, rounding=rounding)):
|
|
return (point_base_accrual / target_points).quantize(
|
|
Decimal("0.0001"),
|
|
rounding=rounding,
|
|
)
|
|
|
|
|
|
def _point_base_accrual(
|
|
rate: EmployeePayRate | None,
|
|
input_lines: list[PayrollInputLine],
|
|
) -> Decimal:
|
|
"""Reproduce the Excel point basis: base salary plus bonus inputs."""
|
|
|
|
base_amount = rate.amount if rate is not None else Decimal("0")
|
|
return base_amount + sum(
|
|
(
|
|
record.amount
|
|
for record in input_lines
|
|
if record.component.code in POINT_BASE_COMPONENT_CODES
|
|
),
|
|
Decimal("0"),
|
|
)
|
|
|
|
|
|
def build_core_request(
|
|
*,
|
|
period: PayrollPeriod,
|
|
rate: EmployeePayRate,
|
|
work_record: PayrollWorkRecord | None,
|
|
input_lines: list[PayrollInputLine],
|
|
rounding: str = ROUND_HALF_UP,
|
|
) -> PayrollRequest:
|
|
target_points = work_record.target_points if work_record else None
|
|
point_base_accrual = _point_base_accrual(rate, input_lines)
|
|
return PayrollRequest(
|
|
employee_ref=f"employee:{rate.employee_id}",
|
|
period=_core_period(period),
|
|
currency=period.currency,
|
|
base_accrual=rate.amount,
|
|
base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}",
|
|
point_base_accrual=point_base_accrual,
|
|
target_points=target_points,
|
|
actual_points=(work_record.actual_points if work_record else None),
|
|
point_rate=_effective_point_rate(
|
|
rate,
|
|
point_base_accrual,
|
|
target_points,
|
|
rounding=rounding,
|
|
),
|
|
point_rate_overridden=rate.point_rate is not None,
|
|
lines=_mapped_input_lines(input_lines),
|
|
expected_totals=(
|
|
_expected_totals(work_record) if work_record is not None else None
|
|
),
|
|
)
|
|
|
|
|
|
def build_core_preview_request(
|
|
*,
|
|
period: PayrollPeriod,
|
|
employee_id: int,
|
|
rate: EmployeePayRate | None,
|
|
target_points: Decimal,
|
|
actual_points: Decimal,
|
|
input_lines: list[PayrollInputLine],
|
|
rounding: str = ROUND_HALF_UP,
|
|
) -> PayrollRequest:
|
|
"""Build a non-persisted calculation request from draft or partial inputs."""
|
|
|
|
point_base_accrual = _point_base_accrual(rate, input_lines)
|
|
return PayrollRequest(
|
|
employee_ref=f"employee:{employee_id}",
|
|
period=_core_period(period),
|
|
currency=period.currency,
|
|
base_accrual=rate.amount if rate is not None else Decimal("0"),
|
|
base_source_ref=(
|
|
f"pay-rate:{rate.pk}:revision:{rate.revision}"
|
|
if rate is not None
|
|
else "preview:missing-pay-rate"
|
|
),
|
|
point_base_accrual=point_base_accrual,
|
|
target_points=target_points,
|
|
actual_points=actual_points,
|
|
point_rate=_effective_point_rate(
|
|
rate,
|
|
point_base_accrual,
|
|
target_points,
|
|
rounding=rounding,
|
|
),
|
|
point_rate_overridden=bool(rate is not None and rate.point_rate is not None),
|
|
lines=_mapped_input_lines(input_lines),
|
|
expected_totals=None,
|
|
)
|
|
|
|
|
|
def employee_snapshot(employee) -> dict[str, object]:
|
|
"""Return the minimum identity snapshot needed for a historical payslip."""
|
|
|
|
display_name = employee.get_full_name().strip() or f"Сотрудник #{employee.pk}"
|
|
position = getattr(employee, "position", None)
|
|
return {
|
|
"employee_id": employee.pk,
|
|
"display_name": display_name,
|
|
"position": ({"id": position.pk, "name": position.name} if position else None),
|
|
}
|