191 lines
6.0 KiB
Python
191 lines
6.0 KiB
Python
"""Host configuration translated into immutable payroll-core rules."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Mapping
|
||
from datetime import date
|
||
from decimal import Decimal, InvalidOperation
|
||
|
||
from django.conf import settings
|
||
from django.core.exceptions import ImproperlyConfigured
|
||
|
||
from payroll_core import PayrollRules, PointPolicy, RoundingPolicy
|
||
|
||
DEFAULT_CONFIG = {
|
||
"RULESET_ID": "eusrr-standard",
|
||
"RULESET_VERSION": "2026.09.1",
|
||
"EFFECTIVE_FROM": "2026-01-01",
|
||
"EFFECTIVE_TO": None,
|
||
"POINT_POLICY": PointPolicy.PROPORTIONAL_WITH_EXCESS.value,
|
||
"MONEY_QUANTUM": "0.01",
|
||
"ROUNDING": RoundingPolicy.HALF_UP.value,
|
||
"ALLOW_NEGATIVE_PAYABLE": True,
|
||
"BASE_RATE_CODE": "BASE",
|
||
# Temporary pilot mode: portal administrators receive every payroll
|
||
# operation while the granular role matrix is being tested.
|
||
"SIMPLE_ADMIN_ACCESS": True,
|
||
}
|
||
|
||
|
||
def get_config() -> dict[str, object]:
|
||
config = DEFAULT_CONFIG.copy()
|
||
overrides = getattr(settings, "FINANCE_PAYROLL", {})
|
||
if not isinstance(overrides, Mapping):
|
||
raise ImproperlyConfigured("FINANCE_PAYROLL must be a mapping")
|
||
config.update(overrides)
|
||
return config
|
||
|
||
|
||
def _parse_date(value, key: str, *, optional: bool = False):
|
||
if value is None and optional:
|
||
return None
|
||
if isinstance(value, date):
|
||
return value
|
||
try:
|
||
return date.fromisoformat(str(value))
|
||
except (TypeError, ValueError) as exc:
|
||
raise ImproperlyConfigured(
|
||
f"FINANCE_PAYROLL[{key!r}] must be an ISO date"
|
||
) from exc
|
||
|
||
|
||
def _parse_bool(value, key: str) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
if normalized in {"true", "1", "yes"}:
|
||
return True
|
||
if normalized in {"false", "0", "no"}:
|
||
return False
|
||
raise ImproperlyConfigured(f"FINANCE_PAYROLL[{key!r}] must be a boolean")
|
||
|
||
|
||
def _parse_rounding(value) -> RoundingPolicy:
|
||
if isinstance(value, RoundingPolicy):
|
||
return value
|
||
raw_value = str(value).strip()
|
||
aliases = {
|
||
"half_up": RoundingPolicy.HALF_UP,
|
||
"half_even": RoundingPolicy.HALF_EVEN,
|
||
"down": RoundingPolicy.DOWN,
|
||
}
|
||
try:
|
||
return aliases.get(raw_value.lower()) or RoundingPolicy(raw_value)
|
||
except ValueError as exc:
|
||
raise ImproperlyConfigured(
|
||
"FINANCE_PAYROLL['ROUNDING'] must be half_up, half_even, down "
|
||
"or a canonical Decimal rounding name"
|
||
) from exc
|
||
|
||
|
||
def _parse_point_policy(value) -> PointPolicy:
|
||
if isinstance(value, PointPolicy):
|
||
return value
|
||
try:
|
||
return PointPolicy(str(value).strip().lower())
|
||
except ValueError as exc:
|
||
raise ImproperlyConfigured(
|
||
"FINANCE_PAYROLL['POINT_POLICY'] must be disabled, excess_only "
|
||
"or proportional_with_excess"
|
||
) from exc
|
||
|
||
|
||
def build_rules() -> PayrollRules:
|
||
config = get_config()
|
||
try:
|
||
quantum = Decimal(str(config["MONEY_QUANTUM"]))
|
||
except (KeyError, ValueError, InvalidOperation) as exc:
|
||
raise ImproperlyConfigured(
|
||
"FINANCE_PAYROLL contains an unsupported calculation setting"
|
||
) from exc
|
||
point_policy = _parse_point_policy(config["POINT_POLICY"])
|
||
rounding = _parse_rounding(config["ROUNDING"])
|
||
allow_negative_payable = _parse_bool(
|
||
config["ALLOW_NEGATIVE_PAYABLE"],
|
||
"ALLOW_NEGATIVE_PAYABLE",
|
||
)
|
||
if quantum != Decimal("0.01"):
|
||
raise ImproperlyConfigured(
|
||
"The finance adapter persists two decimal places; "
|
||
"MONEY_QUANTUM must be 0.01"
|
||
)
|
||
return PayrollRules(
|
||
ruleset_id=str(config["RULESET_ID"]),
|
||
version=str(config["RULESET_VERSION"]),
|
||
effective_from=_parse_date(config["EFFECTIVE_FROM"], "EFFECTIVE_FROM"),
|
||
effective_to=_parse_date(
|
||
config.get("EFFECTIVE_TO"),
|
||
"EFFECTIVE_TO",
|
||
optional=True,
|
||
),
|
||
point_policy=point_policy,
|
||
money_quantum=quantum,
|
||
rounding=rounding,
|
||
allow_negative_payable=allow_negative_payable,
|
||
)
|
||
|
||
|
||
def rules_cover_period(
|
||
rules: PayrollRules,
|
||
*,
|
||
period_from: date,
|
||
period_to: date,
|
||
) -> bool:
|
||
"""Return whether one ruleset is effective for the complete payroll period."""
|
||
|
||
return period_from >= rules.effective_from and (
|
||
rules.effective_to is None or period_to <= rules.effective_to
|
||
)
|
||
|
||
|
||
def ruleset_not_effective_message(rules: PayrollRules) -> str:
|
||
"""Build an operator-facing explanation that remains useful without details."""
|
||
|
||
effective_range = f"с {rules.effective_from:%d.%m.%Y}"
|
||
if rules.effective_to is not None:
|
||
effective_range += f" по {rules.effective_to:%d.%m.%Y}"
|
||
return (
|
||
"Для выбранного периода нет действующих правил расчёта. "
|
||
f"Набор {rules.ruleset_id}, версия {rules.version}, применяется "
|
||
f"{effective_range}. Измените период или подключите историческую "
|
||
"версию правил."
|
||
)
|
||
|
||
|
||
def ruleset_period_details(
|
||
rules: PayrollRules,
|
||
*,
|
||
period_from: date,
|
||
period_to: date,
|
||
) -> dict[str, object]:
|
||
"""Stable diagnostics shared by readiness and calculation errors."""
|
||
|
||
ruleset_details = {
|
||
"id": rules.ruleset_id,
|
||
"version": rules.version,
|
||
"effective_from": rules.effective_from.isoformat(),
|
||
}
|
||
if rules.effective_to is not None:
|
||
ruleset_details["effective_to"] = rules.effective_to.isoformat()
|
||
return {
|
||
"period": {
|
||
"date_from": period_from.isoformat(),
|
||
"date_to": period_to.isoformat(),
|
||
},
|
||
"ruleset": ruleset_details,
|
||
}
|
||
|
||
|
||
def base_rate_code() -> str:
|
||
return str(get_config()["BASE_RATE_CODE"])
|
||
|
||
|
||
def simple_admin_access_enabled() -> bool:
|
||
"""Return whether staff administrators bypass the granular payroll matrix."""
|
||
|
||
return _parse_bool(
|
||
get_config()["SIMPLE_ADMIN_ACCESS"],
|
||
"SIMPLE_ADMIN_ACCESS",
|
||
)
|