- Add 202512_GenesysCX TEI study (config, seed data, notebooks, README) with NPV $10.8M / ROI 266% including AI-token cost line - Add explicit `key` parameter to all chart wrappers in app/components to prevent StreamlitDuplicateElementId errors when the same figure type renders across Summary/Benefits/Costs tabs - Render benefits bar and cost pie charts on their respective tabs - Add benefits_vs_costs_by_year chart wrapper
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""
|
|
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
|