""" Business case — combines costs, benefits, and cost takeouts into a 3-year net view with NPV, payback, and ROI. Convention: all cashflows are year-end and discounted at ``discount_rate`` (default 8%); there is no undiscounted year-0 column — implementation is amortized straight-line across the analysis years (spec §5.6 "Implementation amort." line). """ from __future__ import annotations import pandas as pd from .benefit_model import calculate_total_benefit from .cost_model import calculate_total_cost from .defaults import ( DEFAULT_DISCOUNT_RATE, DEFAULT_IMPLEMENTATION_COST, PLATFORM_RATE_PER_USER_MONTHLY, ) from .inputs import CostTakeout, FeatureScope, SiteInput from .meters import Confidence, TokenMeter, TokenPricing from .rollout import RolloutPlan from .scenarios import Scenario, get_scenario def npv(cashflows_by_year: list[float], discount_rate: float) -> float: """Year-end-discounted NPV of year-1..N cashflows.""" return sum( cf / (1 + discount_rate) ** year for year, cf in enumerate(cashflows_by_year, start=1) ) def payback_years(cashflows_by_year: list[float]) -> float | None: """First (fractional) year cumulative net turns >= 0; None if never. Cashflows are assumed evenly spread within each year. """ cumulative = 0.0 for year, cf in enumerate(cashflows_by_year, start=1): if cumulative + cf >= 0 and cf != 0: if cumulative >= 0: return float(year - 1) return (year - 1) + (-cumulative / cf) cumulative += cf return None def build_business_case( sites: list[SiteInput], feature_scopes: list[FeatureScope], meters: dict[str, TokenMeter], pricing: dict[str, TokenPricing], takeouts: list[CostTakeout], scenario: str | Scenario, years: int = 3, discount_rate: float = DEFAULT_DISCOUNT_RATE, platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY, implementation_cost: float = DEFAULT_IMPLEMENTATION_COST, use_contracted: bool = False, benefit_params: str = "realistic", rollout: RolloutPlan | None = None, ) -> dict: """Returns the dict described in spec §4.3 (DataFrames + headline metrics). Every number traces to a cost line, benefit line, or takeout row in the per-year detail frames. """ sc = get_scenario(scenario) if isinstance(scenario, str) else scenario year_cols = [f"Y{y}" for y in range(1, years + 1)] cost_frames, benefit_frames = {}, {} for y in range(1, years + 1): cost_frames[y] = calculate_total_cost( sites, feature_scopes, meters, pricing, sc, y, platform_rate=platform_rate, use_contracted=use_contracted, rollout=rollout, ) benefit_frames[y] = calculate_total_benefit( sites, feature_scopes, sc, y, params=benefit_params, rollout=rollout, ) # ── cost_by_year: one row per cost line, one column per year ──── cost_lines = list(cost_frames[1]["cost_line"]) cost_by_year = pd.DataFrame({"line": cost_lines}) for y in range(1, years + 1): cost_by_year[f"Y{y}"] = list(cost_frames[y]["annual_cost"]) cost_by_year["confidence"] = list(cost_frames[1]["confidence"]) if implementation_cost: amort = implementation_cost / years cost_by_year = pd.concat( [ cost_by_year, pd.DataFrame( [ { "line": "Implementation (amortized)", **{c: amort for c in year_cols}, "confidence": Confidence.ESTIMATED.value, } ] ), ], ignore_index=True, ) # ── benefit_by_year ────────────────────────────────────────────── benefit_lines: list[str] = [] for y in range(1, years + 1): for line in benefit_frames[y]["benefit_line"]: if line not in benefit_lines: benefit_lines.append(line) benefit_by_year = pd.DataFrame({"line": benefit_lines}) for y in range(1, years + 1): lookup = dict( zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["annual_value"]) ) benefit_by_year[f"Y{y}"] = [lookup.get(line, 0.0) for line in benefit_lines] conf_lookup: dict[str, str] = {} for y in range(1, years + 1): conf_lookup.update( dict(zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["confidence"])) ) benefit_by_year["confidence"] = [ conf_lookup.get(line, Confidence.ESTIMATED.value) for line in benefit_lines ] # ── takeouts_by_year ───────────────────────────────────────────── takeouts_by_year = pd.DataFrame( [ { "line": t.name, **{f"Y{y}": t.value_in_year(y) for y in range(1, years + 1)}, "confidence": t.confidence.value, } for t in takeouts ] ) # ── net + cumulative ───────────────────────────────────────────── total_costs = [float(cost_by_year[c].sum()) for c in year_cols] total_benefits = [float(benefit_by_year[c].sum()) for c in year_cols] total_takeouts = [ float(takeouts_by_year[c].sum()) if not takeouts_by_year.empty else 0.0 for c in year_cols ] net = [ b + t - c for b, t, c in zip(total_benefits, total_takeouts, total_costs) ] cumulative = pd.Series(net).cumsum().tolist() net_by_year = pd.DataFrame( { "line": [ "TOTAL COSTS", "TOTAL TAKEOUTS", "TOTAL BENEFITS", "NET", "Cumulative net", ], **{ f"Y{y}": [ total_costs[y - 1], total_takeouts[y - 1], total_benefits[y - 1], net[y - 1], cumulative[y - 1], ] for y in range(1, years + 1) }, } ) cumulative_net = pd.DataFrame( {"year": list(range(1, years + 1)), "cumulative_net": cumulative} ) total_cost_sum = sum(total_costs) total_value_sum = sum(total_benefits) + sum(total_takeouts) return { "cost_by_year": cost_by_year, "benefit_by_year": benefit_by_year, "takeouts_by_year": takeouts_by_year, "net_by_year": net_by_year, "cumulative_net": cumulative_net, "npv": npv(net, discount_rate), "payback_period_years": payback_years(net), "roi_3yr": ( (total_value_sum - total_cost_sum) / total_cost_sum if total_cost_sum else None ), }