import type { Assessment, Candidate, Catalog, Weights } from "./types"; export const DEFAULT_WEIGHTS: Weights = { capability: 35, switchEase: 25, timeToRoi: 25, spend: 15, }; const clamp01 = (n: number) => Math.min(1, Math.max(0, n)); /** ROI speed as a 0–1 score: <=6 months is ideal, >=36 months scores zero. */ export function roiNorm(months: number) { return clamp01(1 - (months - 6) / 30); } export type CandidateMetrics = { annualSavings: number; horizonGross: number; horizonNet: number; paybackMonths: number | null; roiPct: number | null; score: number; wave: 1 | 2 | 3; }; export type Rollup = { metrics: Map; included: Candidate[]; totalAnnualSpend: number; addressableSpend: number; totalAnnualSavings: number; totalMigrationCost: number; horizonGross: number; horizonNet: number; blendedPaybackMonths: number | null; savingsPct: number; avgScore: number; toolsRetired: number; vendorsRetired: number; /** Tools the customer runs after consolidation: our distinct products plus anything left unmapped. */ toolsAfter: number; /** Vendors after consolidation: us (if anything is mapped) plus the vendors of unmapped tools. */ vendorsAfter: number; unmappedCount: number; byWave: Record<1 | 2 | 3, Candidate[]>; byBu: BuValue[]; byDomain: { domainId: string; name: string; savings: number; spend: number; count: number }[]; }; export type BuValue = { buId: string; name: string; short: string; color: string; savings: number; spend: number; migrationCost: number; horizonNet: number; /** Incumbent lines this BU absorbs (a line mapped to two BUs counts for both). */ count: number; productIds: string[]; }; export function scoreCandidate( c: Candidate, weights: Weights, maxSpend: number, horizonYears: number, ): CandidateMetrics { const cap = clamp01((c.capability - 1) / 4); const ease = clamp01((c.switchEase - 1) / 4); const roi = roiNorm(c.timeToRoiMonths); // Square-root scaling so a single very large line item does not swamp the rest. const spendN = maxSpend > 0 ? clamp01(Math.sqrt(Math.max(0, c.annualSpend)) / Math.sqrt(maxSpend)) : 0; const w = weights; const wSum = w.capability + w.switchEase + w.timeToRoi + w.spend || 1; const score = (100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum; const annualSavings = (c.annualSpend * c.savingsPct) / 100; const horizonGross = annualSavings * horizonYears; const horizonNet = horizonGross - c.migrationCost; const paybackMonths = annualSavings > 0 ? (c.migrationCost / annualSavings) * 12 : c.migrationCost > 0 ? null : 0; const roiPct = c.migrationCost > 0 ? (horizonNet / c.migrationCost) * 100 : null; const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3; return { annualSavings, horizonGross, horizonNet, paybackMonths, roiPct, score, wave }; } export function rollup(a: Assessment, catalog: Catalog): Rollup { const included = a.candidates.filter((c) => c.included); const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend)); const metrics = new Map(); for (const c of a.candidates) { metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears)); } const totalAnnualSpend = a.candidates.reduce((s, c) => s + c.annualSpend, 0); const addressableSpend = included.reduce((s, c) => s + c.annualSpend, 0); const totalAnnualSavings = included.reduce((s, c) => s + metrics.get(c.id)!.annualSavings, 0); const totalMigrationCost = included.reduce((s, c) => s + c.migrationCost, 0); const horizonGross = totalAnnualSavings * a.horizonYears; const horizonNet = horizonGross - totalMigrationCost; const blendedPaybackMonths = totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null; const avgScore = included.length ? included.reduce((s, c) => s + metrics.get(c.id)!.score, 0) / included.length : 0; const byWave: Record<1 | 2 | 3, Candidate[]> = { 1: [], 2: [], 3: [] }; for (const c of included) byWave[metrics.get(c.id)!.wave].push(c); const prodById = new Map(catalog.products.map((p) => [p.id, p])); // Savings are split evenly across the mapped products so BU credit sums to the total. const buAgg = new Map }>(); for (const c of included) { const m = metrics.get(c.id)!; const bus = [...new Set(c.productIds.map((id) => prodById.get(id)?.bu).filter(Boolean))] as string[]; if (!bus.length) continue; for (const bu of bus) { const e = buAgg.get(bu) ?? { savings: 0, spend: 0, migrationCost: 0, count: 0, products: new Set() }; e.savings += m.annualSavings / bus.length; e.spend += c.annualSpend / bus.length; e.migrationCost += c.migrationCost / bus.length; e.count += 1; for (const id of c.productIds) if (prodById.get(id)?.bu === bu) e.products.add(id); buAgg.set(bu, e); } } const byBu: BuValue[] = catalog.businessUnits .filter((b) => buAgg.has(b.id)) .map((b) => { const e = buAgg.get(b.id)!; return { buId: b.id, name: b.name, short: b.short, color: b.color, savings: e.savings, spend: e.spend, migrationCost: e.migrationCost, horizonNet: e.savings * a.horizonYears - e.migrationCost, count: e.count, productIds: [...e.products], }; }) .sort((x, y) => y.savings - x.savings); const mapped = included.filter((c) => c.productIds.length); const unmapped = included.filter((c) => !c.productIds.length); const vendorKey = (c: Candidate) => c.incumbentVendor.trim().toLowerCase(); const toolsAfter = new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length; const vendorsAfter = (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size; const domAgg = new Map(); for (const c of included) { const m = metrics.get(c.id)!; const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 }; e.savings += m.annualSavings; e.spend += c.annualSpend; e.count += 1; domAgg.set(c.domain, e); } const byDomain = catalog.domains .filter((d) => domAgg.has(d.id)) .map((d) => ({ domainId: d.id, name: d.name, ...domAgg.get(d.id)! })) .sort((x, y) => y.savings - x.savings); return { metrics, included, totalAnnualSpend, addressableSpend, totalAnnualSavings, totalMigrationCost, horizonGross, horizonNet, blendedPaybackMonths, savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0, avgScore, toolsRetired: included.length, vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size, toolsAfter, vendorsAfter, unmappedCount: unmapped.length, byWave, byDomain, byBu, }; } export const WAVE_LABEL: Record<1 | 2 | 3, string> = { 1: "Wave 1 — Quick wins", 2: "Wave 2 — Planned consolidation", 3: "Wave 3 — Strategic / longer horizon", }; export const WAVE_BLURB: Record<1 | 2 | 3, string> = { 1: "Strong functional parity, low switching friction and fast payback. Start here.", 2: "Clear consolidation value with a manageable migration. Schedule against contract renewals.", 3: "Material savings but higher effort, lower parity or a longer payback. Sequence last or revisit scope.", };