323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""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}")
|