"""Mapping between approved Django records and payroll-core DTOs.""" from __future__ import annotations from collections import defaultdict from decimal import ROUND_HALF_UP, Context, Decimal, localcontext from finance.enums import ApprovalStatus, InputSource, PayrollComponentKind from finance.models import ( EmployeePayRate, PayrollInputLine, PayrollPeriod, PayrollWorkRecord, ) from payroll_core import ( ExpectedTotals, InputLine, LineKind, PayrollRequest, ) from payroll_core import ( PayrollPeriod as CorePayrollPeriod, ) from .config import base_rate_code from .scheduled_changes import effective_period_lines POINT_BASE_NEGATIVE_KINDS = frozenset( { "adjustment_debit", "deduction", "payment", } ) 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 _component_map(period, component_definitions=None): if component_definitions is not None: return component_definitions from .components import get_payroll_components return {component.code: component for component in get_payroll_components(period)} def _mapped_input_lines( input_lines: list[PayrollInputLine], component_definitions, ) -> tuple[InputLine, ...]: relevant_lines = [ record for record in input_lines if component_definitions[record.component_code].kind != PayrollComponentKind.SCHEDULED_CHANGE ] signed_codes = { record.component_code for record in relevant_lines if record.value_sign < 0 } mapped_lines = [] for component_code in signed_codes: records = [ record for record in relevant_lines if record.component_code == component_code ] component = component_definitions[component_code] amount = sum((record.signed_amount for record in records), Decimal("0")) if amount < 0: raise ValueError( f"Итоговое значение колонки {component.name} не может быть отрицательным." ) if amount == 0: continue mapped_lines.append( InputLine( line_id=( f"effective:{component_code}:" + ",".join(str(record.pk) for record in records) ), code=component.code, label=component.name, kind=LineKind(component.kind), amount=amount, source_ref="scheduled-effective-value", include_in_salary=component.include_in_salary, reason="; ".join( dict.fromkeys(record.reason for record in records if record.reason) ), include_in_carryover=( component.include_in_carryover and any(record.include_in_carryover for record in records) ), ) ) for record in relevant_lines: if record.component_code in signed_codes: continue component = component_definitions[record.component_code] 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=component.code, label=component.name, kind=LineKind(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, include_in_salary=component.include_in_salary, include_in_carryover=( component.include_in_carryover and record.include_in_carryover ), ) ) 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], component_definitions, ) -> Decimal: """Return the amount whose payout is proportional to actual points. Component settings explicitly decide which columns participate. By default only the regular bonus is included; both correction columns remain fixed changes of the final amount. """ base_amount = rate.amount if rate is not None else Decimal("0") point_base_lines = Decimal("0") for record in input_lines: component = component_definitions[record.component_code] if not component.include_in_point_base or not component.include_in_salary: continue direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1 if component.kind == PayrollComponentKind.SCHEDULED_CHANGE: continue point_base_lines += record.signed_amount * direction return base_amount + point_base_lines def point_base_accruals_for_employees( *, period: PayrollPeriod, employee_ids, component_definitions=None, ) -> dict[int, Decimal]: """Return current editable point bases using the table-preview semantics.""" normalized_ids = {int(employee_id) for employee_id in employee_ids} if not normalized_ids: return {} grouped_rates = defaultdict(list) rates = EmployeePayRate.objects.filter( employee_id__in=normalized_ids, rate_code=base_rate_code(), status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED], effective_from__lte=period.date_from, ).order_by("employee_id", "effective_from", "revision", "id") for rate in rates: grouped_rates[rate.employee_id].append(rate) selected_rates = { employee_id: max( records, key=lambda record: ( record.effective_from, record.revision, record.pk, ), ) for employee_id, records in grouped_rates.items() } grouped_lines = defaultdict(list) lines = ( PayrollInputLine.objects.filter( period=period, employee_id__in=normalized_ids, status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED], ) .exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:") .select_related("relates_to_period") .order_by("employee_id", "component_code", "id") ) for line in lines: grouped_lines[line.employee_id].append(line) components_by_code = _component_map(period, component_definitions) return { employee_id: _point_base_accrual( selected_rates.get(employee_id), grouped_lines.get(employee_id, []), components_by_code, ) for employee_id in normalized_ids } def build_core_request( *, period: PayrollPeriod, rate: EmployeePayRate, work_record: PayrollWorkRecord | None, input_lines: list[PayrollInputLine], opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, component_definitions=None, ) -> PayrollRequest: components_by_code = _component_map(period, component_definitions) input_lines = effective_period_lines( period, rate.employee, input_lines, scheduled_codes={ code for code, component in components_by_code.items() if component.kind == PayrollComponentKind.SCHEDULED_CHANGE }, ) target_points = work_record.target_points if work_record else None point_base_accrual = _point_base_accrual( rate, input_lines, components_by_code, ) return PayrollRequest( employee_ref=f"employee:{rate.employee_id}", balance_rules_version=period.balance_rules_version, period=_core_period(period), currency=period.currency, base_accrual=rate.amount, base_source_ref=f"pay-rate:{rate.pk}:revision:{rate.revision}", opening_balance=opening_balance, 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, components_by_code), 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], opening_balance: Decimal = Decimal("0"), rounding: str = ROUND_HALF_UP, component_definitions=None, ) -> PayrollRequest: """Build a non-persisted calculation request from draft or partial inputs.""" components_by_code = _component_map(period, component_definitions) point_base_accrual = _point_base_accrual( rate, input_lines, components_by_code, ) return PayrollRequest( employee_ref=f"employee:{employee_id}", balance_rules_version=period.balance_rules_version, 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" ), opening_balance=opening_balance, 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, components_by_code), 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), }