"""Code-owned payroll input component definitions.""" from dataclasses import dataclass, replace from finance.enums import ( PayrollComponentKind, PayrollComponentPeriodScope, PayrollPeriodStatus, ) PAYROLL_COMPONENT_SNAPSHOT_VERSION = 2 PAYROLL_COMPONENT_SNAPSHOT_FIELDS = ( "code", "name", "kind", "requires_reason", "display_order", "include_in_point_base", "include_in_salary", "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_salary: bool = True 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_salary=override.get("include_in_salary", True), 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) ] # Old periods retain their original salary semantics even if a live # column is subsequently excluded from salary in the current period. if period.balance_rules_version == 1: return _PayrollComponentCollection( PayrollComponentDefinition( **{ field: True if field == "include_in_salary" else getattr(component, field) for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS } ) for component in (*builtins, *custom) ) 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_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") not in (1, PAYROLL_COMPONENT_SNAPSHOT_VERSION): raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.") payloads = snapshot.get("components") if not isinstance(payloads, list): raise ValueError("В снимке отсутствует список зарплатных колонок.") try: return _PayrollComponentCollection( PayrollComponentDefinition( **{ field: payload.get(field, True) if field == "include_in_salary" and snapshot["version"] == 1 else 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 or period.balance_rules_version == 1 ) 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}")