Corp/backend/finance/payroll/components.py

323 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Code-owned payroll input component definitions."""
from dataclasses import dataclass, replace
from finance.enums import (
PayrollComponentKind,
PayrollComponentPeriodScope,
PayrollPeriodStatus,
)
PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1
PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
"code",
"name",
"kind",
"requires_reason",
"display_order",
"include_in_point_base",
"include_in_carryover",
"visible_to_employee",
"period_scope",
"scope_period_id",
"is_custom",
"highlight_color",
"scheduled_target_component_code",
)
@dataclass(frozen=True, slots=True)
class PayrollComponentDefinition:
code: str
name: str
kind: str
requires_reason: bool
display_order: int
include_in_point_base: bool = False
include_in_carryover: bool = False
visible_to_employee: bool = True
period_scope: str = PayrollComponentPeriodScope.ALL
scope_period_id: int | None = None
is_custom: bool = False
highlight_color: str = ""
scheduled_target_component_code: str = ""
@property
def id(self) -> str:
return self.code
@property
def pk(self) -> str:
return self.code
@property
def is_active(self) -> bool:
return True
def get_kind_display(self) -> str:
return PayrollComponentKind(self.kind).label
PAYROLL_COMPONENTS = (
PayrollComponentDefinition(
"VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20
),
PayrollComponentDefinition(
"BONUS",
"Премия",
PayrollComponentKind.EARNING,
False,
30,
include_in_point_base=True,
),
PayrollComponentDefinition(
"CORRECTION_CREDIT",
"Положительная корректировка",
PayrollComponentKind.ADJUSTMENT_CREDIT,
True,
40,
include_in_carryover=True,
highlight_color="green",
),
PayrollComponentDefinition(
"CORRECTION_DEBIT",
"Отрицательная корректировка",
PayrollComponentKind.ADJUSTMENT_DEBIT,
True,
41,
include_in_carryover=True,
highlight_color="red",
),
PayrollComponentDefinition(
"ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50
),
PayrollComponentDefinition(
"DEDUCTION", "Удержание", PayrollComponentKind.DEDUCTION, True, 60
),
PayrollComponentDefinition(
"ADVANCE", "Аванс", PayrollComponentKind.PAYMENT, False, 70
),
PayrollComponentDefinition(
"SALARY_PAYMENT", "Фактическая выплата", PayrollComponentKind.PAYMENT, True, 80
),
)
PAYROLL_COMPONENTS_BY_CODE = {
component.code: component for component in PAYROLL_COMPONENTS
}
PAYROLL_COMPONENT_CODE_CHOICES = tuple(
(component.code, component.name) for component in PAYROLL_COMPONENTS
)
class _PayrollComponentCollection(tuple):
def get(self, **criteria):
matches = self.filter(**criteria)
if len(matches) != 1:
raise LookupError(f"Ожидался один вид операции, найдено: {len(matches)}")
return matches[0]
def filter(self, **criteria):
return _PayrollComponentCollection(
component
for component in self
if all(getattr(component, key) == value for key, value in criteria.items())
)
def exclude(self, **criteria):
normalized = {
key.removesuffix("__in"): ("in" if key.endswith("__in") else "exact", value)
for key, value in criteria.items()
}
return _PayrollComponentCollection(
component
for component in self
if not all(
getattr(component, key) in value
if lookup == "in"
else getattr(component, key) == value
for key, (lookup, value) in normalized.items()
)
)
def all(self):
return self
def order_by(self, *fields):
result = list(self)
for field in reversed(fields):
reverse = field.startswith("-")
name = field.removeprefix("-")
result.sort(key=lambda component: getattr(component, name), reverse=reverse)
return _PayrollComponentCollection(result)
def values_list(self, field, *, flat=False):
values = [getattr(component, field) for component in self]
return values if flat else [(value,) for value in values]
class PayrollComponent:
"""Compatibility facade over the code registry; this is not a database model."""
class _Manager:
def all(self):
return get_payroll_components()
def get(self, **criteria):
return self.all().get(**criteria)
def filter(self, **criteria):
return self.all().filter(**criteria)
def exclude(self, **criteria):
return self.all().exclude(**criteria)
def order_by(self, *fields):
return self.all().order_by(*fields)
objects = _Manager()
def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
"""Return built-ins plus active user-created component columns."""
from finance.models import FinanceSettings, PayrollCustomComponent, PayrollPeriod
finance_settings = FinanceSettings.objects.first()
overrides = finance_settings.payroll_component_overrides if finance_settings else {}
builtins = []
for component in PAYROLL_COMPONENTS:
override = overrides.get(component.code, {})
builtins.append(
replace(
component,
name=override.get("name", component.name),
kind=override.get("kind", component.kind),
requires_reason=override.get(
"requires_reason", component.requires_reason
),
include_in_point_base=override.get(
"include_in_point_base", component.include_in_point_base
),
include_in_carryover=override.get(
"include_in_carryover", component.include_in_carryover
),
visible_to_employee=override.get(
"visible_to_employee", component.visible_to_employee
),
highlight_color=override.get(
"highlight_color", component.highlight_color
),
period_scope=override.get("period_scope", component.period_scope),
scope_period_id=override.get(
"scope_period_id", component.scope_period_id
),
scheduled_target_component_code=override.get(
"scheduled_target_component_code",
component.scheduled_target_component_code,
),
)
)
custom = list(
PayrollCustomComponent.objects.filter(is_active=True)
.select_related(
"scope_period",
"hidden_from_period",
)
.prefetch_related("excluded_periods")
.order_by("display_order", "name", "id")
)
if period is not None:
referenced_period_ids = {
component.scope_period_id
for component in builtins
if component.scope_period_id is not None
}
period_dates = dict(
PayrollPeriod.objects.filter(pk__in=referenced_period_ids).values_list(
"pk", "date_from"
)
)
builtins = [
component
for component in builtins
if component.period_scope == PayrollComponentPeriodScope.ALL
or (
component.scope_period_id is not None
and (
(
component.period_scope
== PayrollComponentPeriodScope.CURRENT_ONLY
and component.scope_period_id == period.pk
)
or (
component.period_scope
== PayrollComponentPeriodScope.CURRENT_AND_FUTURE
and period_dates.get(
component.scope_period_id,
period.date_from,
)
<= period.date_from
)
)
)
]
custom = [
component for component in custom if component.applies_to_period(period)
]
return _PayrollComponentCollection((*builtins, *custom))
def build_payroll_component_snapshot(period) -> dict[str, object]:
"""Serialize the exact component configuration visible when a period closes."""
components = _get_live_payroll_components(period)
return {
"version": PAYROLL_COMPONENT_SNAPSHOT_VERSION,
"components": [
{
field: getattr(component, field)
for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
}
for component in components
],
}
def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
if not isinstance(snapshot, dict):
raise ValueError("Некорректный снимок зарплатных колонок.")
if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION:
raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.")
payloads = snapshot.get("components")
if not isinstance(payloads, list):
raise ValueError("В снимке отсутствует список зарплатных колонок.")
try:
return _PayrollComponentCollection(
PayrollComponentDefinition(
**{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS}
)
for payload in payloads
)
except (KeyError, TypeError) as exc:
raise ValueError("Снимок зарплатных колонок повреждён.") from exc
def get_payroll_components(period=None) -> _PayrollComponentCollection:
"""Return frozen columns for closed periods and live columns otherwise."""
if (
period is not None
and period.status == PayrollPeriodStatus.CLOSED
and period.component_snapshot
):
return _components_from_snapshot(period.component_snapshot)
return _get_live_payroll_components(period)
def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition:
component = get_payroll_components(period).filter(code=code)
if component:
return component[0]
raise ValueError(f"Неизвестный вид зарплатной операции: {code}")