Corp/backend/finance/payroll/components.py
lizerk 63d77c38fa
All checks were successful
Deploy production / deploy (push) Successful in 10m58s
fix(finance): defer markers until next period exists
2026-09-11 15:50:06 +03:00

255 lines
8.4 KiB
Python

"""Code-owned payroll input component definitions."""
from dataclasses import dataclass, replace
from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope
@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_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 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}")