""" Input bundles — validated dataclasses, no untyped dicts. All volumes are MONTHLY; all AHT/ACW figures are SECONDS; all labour costs are ANNUAL fully-loaded USD. """ from __future__ import annotations from dataclasses import dataclass, field from .meters import Confidence #: Sanity bounds for handle times (seconds). AHT_MIN_SECONDS = 10 AHT_MAX_SECONDS = 3600 #: Working hours per FTE-year used to derive per-second labour rates. WORKING_HOURS_PER_YEAR = 2_080 WORKING_SECONDS_PER_YEAR = WORKING_HOURS_PER_YEAR * 3600 @dataclass class SiteInput: site_name: str # "NAM", "EMEA", "AUZ", "APAC HK", … region_pricing: str # "US", "AU", "EU", "APAC" agents: int # excluding supervisors supervisors: int voice_volume_monthly: int email_volume_monthly: int chat_volume_monthly: int sms_volume_monthly: int voice_aht_seconds: int email_aht_seconds: int chat_aht_seconds: int voice_acw_seconds: int fully_loaded_agent_cost_annual: float fully_loaded_supervisor_cost_annual: float languages: list[str] = field(default_factory=list) def __post_init__(self) -> None: if self.agents < 0 or self.supervisors < 0: raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0") for name in ( "voice_volume_monthly", "email_volume_monthly", "chat_volume_monthly", "sms_volume_monthly", ): if getattr(self, name) < 0: raise ValueError(f"{self.site_name}: {name} must be >= 0") for name in ("voice_aht_seconds", "email_aht_seconds", "chat_aht_seconds"): v = getattr(self, name) if v and not AHT_MIN_SECONDS <= v <= AHT_MAX_SECONDS: raise ValueError( f"{self.site_name}: {name}={v}s outside sensible bounds " f"({AHT_MIN_SECONDS}-{AHT_MAX_SECONDS}s)" ) if self.voice_acw_seconds < 0: raise ValueError(f"{self.site_name}: voice_acw_seconds must be >= 0") @property def named_users(self) -> int: return self.agents + self.supervisors @property def agent_cost_per_second(self) -> float: """Fully-loaded agent labour rate per working second (DBZ-safe).""" return self.fully_loaded_agent_cost_annual / WORKING_SECONDS_PER_YEAR @property def supervisor_cost_per_second(self) -> float: return self.fully_loaded_supervisor_cost_annual / WORKING_SECONDS_PER_YEAR @dataclass class FeatureScope: """Which feature is enabled at which sites, in which phase. ``phase`` is the model year (1-3) the feature switches on; ``adoption_curve`` maps model year -> adoption fraction (0.0-1.0) applied to consumption-metered features (per-user licenses are paid in full from the phase year onward). """ feature: str enabled_sites: list[str] phase: int = 1 adoption_curve: dict[int, float] = field(default_factory=dict) deflection_target: float | None = None eligibility_pct: float | None = None def __post_init__(self) -> None: if self.phase < 1: raise ValueError(f"{self.feature}: phase must be >= 1") for year, pct in self.adoption_curve.items(): if not 0.0 <= pct <= 1.0: raise ValueError( f"{self.feature}: adoption_curve[{year}]={pct} outside 0-1" ) for name in ("deflection_target", "eligibility_pct"): v = getattr(self, name) if v is not None and not 0.0 <= v <= 1.0: raise ValueError(f"{self.feature}: {name}={v} outside 0-1") def active(self, site_name: str, year: int) -> bool: return site_name in self.enabled_sites and year >= self.phase def adoption(self, year: int) -> float: """Adoption fraction for ``year`` (1.0 when no curve given).""" if not self.adoption_curve: return 1.0 if year in self.adoption_curve: return self.adoption_curve[year] # Past the last defined year → hold the last value. last = max(self.adoption_curve) return self.adoption_curve[last] if year > last else 0.0 @dataclass class CostTakeout: """A retired platform/licence whose cost the programme reclaims. ``start_month`` (1-12, within ``start_year``) prorates the first active year — e.g. NICE IEX can only be switched off once NAM is live, so start_year=1, start_month=7 reclaims 6/12 of Y1. """ name: str # "NICE IEX (NAM)", "Legacy CC platform", … annual_cost: float start_year: int = 1 confidence: Confidence = Confidence.ESTIMATED notes: str = "" start_month: int = 1 def __post_init__(self) -> None: if self.annual_cost < 0: raise ValueError(f"{self.name}: annual_cost must be >= 0") if self.start_year < 1: raise ValueError(f"{self.name}: start_year must be >= 1") if not 1 <= self.start_month <= 12: raise ValueError(f"{self.name}: start_month must be 1-12") def value_in_year(self, year: int) -> float: if year < self.start_year: return 0.0 if year == self.start_year: return self.annual_cost * (12 - (self.start_month - 1)) / 12 return self.annual_cost