Sprint 2 engine: TCO, investment, ramp-aware cash flow, scenarios, planner overrides, shelfware, KB curation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
25
src/app/api/assessments/[id]/snapshots/[sid]/route.ts
Normal file
25
src/app/api/assessments/[id]/snapshots/[sid]/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSnapshot, getAssessment, getSnapshot, saveAssessment } from "@/lib/store";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string; sid: string }> };
|
||||
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { id, sid } = await params;
|
||||
const s = await getSnapshot(id, sid);
|
||||
return s ? NextResponse.json(s) : NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore: the current state is snapshotted first, so a restore can itself be
|
||||
* undone. The assessment keeps its id; only its content rolls back.
|
||||
*/
|
||||
export async function POST(req: Request, { params }: Ctx) {
|
||||
const { id, sid } = await params;
|
||||
const body = (await req.json().catch(() => ({}))) as { action?: string };
|
||||
if (body.action !== "restore") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
const [current, snap] = await Promise.all([getAssessment(id), getSnapshot(id, sid)]);
|
||||
if (!current || !snap) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
await createSnapshot(current, `Before restoring “${snap.label}”`, "restore");
|
||||
const restored = await saveAssessment({ ...snap.assessment, id, createdAt: current.createdAt });
|
||||
return NextResponse.json(restored);
|
||||
}
|
||||
20
src/app/api/assessments/[id]/snapshots/route.ts
Normal file
20
src/app/api/assessments/[id]/snapshots/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSnapshot, getAssessment, listSnapshots } from "@/lib/store";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
if (!(await getAssessment(id))) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(await listSnapshots(id));
|
||||
}
|
||||
|
||||
/** Body: { label, trigger?: "manual" | "print" } — exports snapshot themselves server-side. */
|
||||
export async function POST(req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
const a = await getAssessment(id);
|
||||
if (!a) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const body = (await req.json().catch(() => ({}))) as { label?: string; trigger?: string };
|
||||
const trigger = body.trigger === "print" ? "print" : "manual";
|
||||
return NextResponse.json(await createSnapshot(a, body.label ?? "", trigger));
|
||||
}
|
||||
10
src/app/api/portfolio/route.ts
Normal file
10
src/app/api/portfolio/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAssessment, getCatalog, listAssessments } from "@/lib/store";
|
||||
import { buildPortfolio } from "@/lib/portfolio";
|
||||
import type { Assessment } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
const [summaries, catalog] = await Promise.all([listAssessments(), getCatalog()]);
|
||||
const all = (await Promise.all(summaries.map((s) => getAssessment(s.id)))).filter(Boolean) as Assessment[];
|
||||
return NextResponse.json(buildPortfolio(all, catalog));
|
||||
}
|
||||
22
src/app/api/settings/route.ts
Normal file
22
src/app/api/settings/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBranding, saveBranding } from "@/lib/store";
|
||||
import { isLogoDataUrl } from "@/lib/images";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await getBranding());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) return NextResponse.json({ error: "Malformed request" }, { status: 400 });
|
||||
const accent = typeof body.accent === "string" && /^#[0-9a-fA-F]{6}$/.test(body.accent) ? body.accent : null;
|
||||
if (!accent) return NextResponse.json({ error: "Accent must be a hex colour like #005C8A." }, { status: 400 });
|
||||
if (body.logo !== "" && !isLogoDataUrl(body.logo)) {
|
||||
return NextResponse.json({ error: "Logo must be a PNG or JPEG under 600 KB." }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await saveBranding({
|
||||
companyName: String(body.companyName ?? "").trim().slice(0, 80),
|
||||
accent,
|
||||
logo: (body.logo as string) || "",
|
||||
}));
|
||||
}
|
||||
153
src/lib/finance.ts
Normal file
153
src/lib/finance.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Assessment, Catalog, Scenario } from "./types";
|
||||
import { type Rollup, financeOf, rollup } from "./scoring";
|
||||
import { type Plan, DEFAULT_PARALLEL, buildPlan } from "./timeline";
|
||||
|
||||
/**
|
||||
* Ramp-aware cash flow: month by month over the planning horizon, driven by
|
||||
* the implementation plan.
|
||||
*
|
||||
* - Cost removed arrives per line from its savings date (cutover, or contract end).
|
||||
* - Our subscription is billed in full from month 0 — how enterprise agreements
|
||||
* are actually billed, and the conservative choice while savings ramp in.
|
||||
* - Migration and a line's services land when its project starts; deal-level
|
||||
* services not attributed to a planned line land in month 0.
|
||||
* - Unmapped lines have no replacement and so contribute nothing.
|
||||
*/
|
||||
|
||||
export type YearFlow = { year: number; removed: number; subscription: number; oneTime: number; net: number; cumulative: number };
|
||||
|
||||
export type CashFlow = {
|
||||
months: number[];
|
||||
years: YearFlow[];
|
||||
/** Net over the horizon, ramp included. The headline figure. */
|
||||
horizonNet: number;
|
||||
npv: number;
|
||||
/** Annualised IRR over the horizon, %; null when the flows never change sign. */
|
||||
irr: number | null;
|
||||
/** Month the cumulative position turns positive; 0 if never negative, null if never recovered. */
|
||||
paybackMonth: number | null;
|
||||
/** Net annual benefit once every planned line is live. */
|
||||
runRateNet: number;
|
||||
oneTimeTotal: number;
|
||||
};
|
||||
|
||||
export function cashFlow(a: Assessment, r: Rollup, plan: Plan): CashFlow {
|
||||
const f = financeOf(a);
|
||||
const horizon = a.horizonYears * 12;
|
||||
const months = new Array<number>(horizon).fill(0);
|
||||
const monthlySub = r.hasInvestment ? r.totalSubscription / 12 : 0;
|
||||
|
||||
let attributedServices = 0;
|
||||
for (const it of plan.items) {
|
||||
const m = r.metrics.get(it.id)!;
|
||||
const annual = r.hasInvestment ? it.grossRemoved : it.annualSavings;
|
||||
for (let t = it.savingsFrom; t < horizon; t++) months[t] += annual / 12;
|
||||
if (it.start < horizon) months[it.start] -= it.migrationCost;
|
||||
attributedServices += m.services;
|
||||
}
|
||||
const serviceRemainder = r.hasInvestment ? Math.max(0, f.investment.services - attributedServices) : 0;
|
||||
if (horizon > 0) months[0] -= serviceRemainder;
|
||||
for (let t = 0; t < horizon; t++) months[t] -= monthlySub;
|
||||
|
||||
const years: YearFlow[] = [];
|
||||
let cumulative = 0;
|
||||
for (let y = 0; y < a.horizonYears; y++) {
|
||||
let removed = 0, oneTime = 0;
|
||||
for (const it of plan.items) {
|
||||
const annual = r.hasInvestment ? it.grossRemoved : it.annualSavings;
|
||||
const live = Math.max(0, Math.min(12, (y + 1) * 12 - Math.max(it.savingsFrom, y * 12)));
|
||||
removed += (annual / 12) * live;
|
||||
if (it.start >= y * 12 && it.start < (y + 1) * 12) oneTime += it.migrationCost;
|
||||
}
|
||||
if (y === 0) oneTime += serviceRemainder;
|
||||
const subscription = monthlySub * 12;
|
||||
const net = removed - subscription - oneTime;
|
||||
cumulative += net;
|
||||
years.push({ year: y + 1, removed, subscription, oneTime, net, cumulative });
|
||||
}
|
||||
|
||||
const monthlyRate = Math.pow(1 + f.discountRate / 100, 1 / 12) - 1;
|
||||
const npvAt = (rate: number) => months.reduce((s, v, t) => s + v / Math.pow(1 + rate, t + 1), 0);
|
||||
const npv = npvAt(monthlyRate);
|
||||
|
||||
let paybackMonth: number | null = 0;
|
||||
let run = 0, wentNegative = false;
|
||||
for (let t = 0; t < horizon; t++) {
|
||||
run += months[t];
|
||||
if (run < 0) { wentNegative = true; paybackMonth = null; }
|
||||
else if (wentNegative && paybackMonth === null) paybackMonth = t + 1;
|
||||
}
|
||||
if (wentNegative && run < 0) paybackMonth = null;
|
||||
|
||||
const planned = plan.items.reduce((s, it) => s + (r.hasInvestment ? it.grossRemoved : it.annualSavings), 0);
|
||||
const oneTimeTotal = plan.items.reduce((s, it) => s + it.migrationCost, 0) + serviceRemainder;
|
||||
|
||||
return {
|
||||
months, years,
|
||||
horizonNet: months.reduce((s, v) => s + v, 0),
|
||||
npv,
|
||||
irr: irr(months),
|
||||
paybackMonth,
|
||||
runRateNet: planned - monthlySub * 12,
|
||||
oneTimeTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Monthly IRR by bisection, annualised. Null when there is no sign change to solve for. */
|
||||
function irr(flows: number[]): number | null {
|
||||
if (!flows.some((v) => v < 0) || !flows.some((v) => v > 0)) return null;
|
||||
const npv = (rate: number) => flows.reduce((s, v, t) => s + v / Math.pow(1 + rate, t + 1), 0);
|
||||
let lo = -0.99, hi = 1;
|
||||
if (npv(lo) * npv(hi) > 0) return null;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (npv(lo) * npv(mid) <= 0) hi = mid; else lo = mid;
|
||||
}
|
||||
return (Math.pow(1 + (lo + hi) / 2, 12) - 1) * 100;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ scenarios */
|
||||
|
||||
export const EXPECTED_ID = "expected";
|
||||
|
||||
/** Conservative / Expected / Aggressive, derived from the assessment as it stands. */
|
||||
export function presetScenarios(a: Assessment): Scenario[] {
|
||||
const p = a.planParallel ?? DEFAULT_PARALLEL;
|
||||
return [
|
||||
{ id: "conservative", name: "Conservative", kind: "preset", valueFactor: 0.75, parallel: Math.max(1, p - 1) },
|
||||
{ id: EXPECTED_ID, name: "Expected", kind: "preset", valueFactor: 1 },
|
||||
{ id: "aggressive", name: "Aggressive", kind: "preset", valueFactor: 1.15, parallel: Math.min(4, p + 1) },
|
||||
];
|
||||
}
|
||||
|
||||
export function allScenarios(a: Assessment): Scenario[] {
|
||||
return [...presetScenarios(a), ...(a.scenarios ?? [])];
|
||||
}
|
||||
|
||||
export function findScenario(a: Assessment, id: string | undefined): Scenario {
|
||||
const all = allScenarios(a);
|
||||
return all.find((s) => s.id === (id ?? EXPECTED_ID)) ?? all.find((s) => s.id === EXPECTED_ID)!;
|
||||
}
|
||||
|
||||
/** The assessment as a scenario sees it. */
|
||||
export function applyScenario(a: Assessment, s: Scenario): Assessment {
|
||||
const next = structuredClone(a);
|
||||
if (s.excluded?.length) {
|
||||
const out = new Set(s.excluded);
|
||||
for (const c of next.candidates) if (out.has(c.id)) c.included = false;
|
||||
}
|
||||
if (s.parallel) next.planParallel = s.parallel;
|
||||
if (s.investment) next.finance = { ...financeOf(next), investment: { ...s.investment } };
|
||||
return next;
|
||||
}
|
||||
|
||||
export type Evaluation = { scenario: Scenario; a: Assessment; r: Rollup; plan: Plan; cash: CashFlow };
|
||||
|
||||
/** Everything a view needs for one scenario: rollup, plan and cash flow. */
|
||||
export function evaluate(a: Assessment, catalog: Catalog, s?: Scenario, today = new Date()): Evaluation {
|
||||
const scenario = s ?? findScenario(a, EXPECTED_ID);
|
||||
const sa = applyScenario(a, scenario);
|
||||
const r = rollup(sa, catalog, scenario.valueFactor);
|
||||
const plan = buildPlan(sa, r, catalog, today);
|
||||
return { scenario, a: sa, r, plan, cash: cashFlow(sa, r, plan) };
|
||||
}
|
||||
27
src/lib/images.ts
Normal file
27
src/lib/images.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Logos travel as data URLs inside JSON, so they are checked for type and size at every boundary. */
|
||||
export const MAX_LOGO_BYTES = 600_000;
|
||||
|
||||
export function isLogoDataUrl(v: unknown): v is string {
|
||||
if (typeof v !== "string") return false;
|
||||
const m = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(v);
|
||||
return !!m && (m[2].length * 3) / 4 <= MAX_LOGO_BYTES;
|
||||
}
|
||||
|
||||
/** PNG / JPEG pixel size from the file header, so exports can keep the aspect ratio. */
|
||||
export function imageSize(dataUrl: string): { w: number; h: number; type: "png" | "jpg" } | null {
|
||||
const m = /^data:image\/(png|jpeg);base64,(.+)$/.exec(dataUrl);
|
||||
if (!m) return null;
|
||||
const b = Buffer.from(m[2], "base64");
|
||||
if (m[1] === "png" && b.length > 24) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), type: "png" };
|
||||
// JPEG: walk the segments to the first start-of-frame marker.
|
||||
let i = 2;
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) { i++; continue; }
|
||||
const marker = b[i + 1];
|
||||
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
||||
return { h: b.readUInt16BE(i + 5), w: b.readUInt16BE(i + 7), type: "jpg" };
|
||||
}
|
||||
i += 2 + b.readUInt16BE(i + 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
131
src/lib/kb.ts
131
src/lib/kb.ts
@@ -141,13 +141,16 @@ export function learnFromAssessment(kb: KnowledgeBase, a: Assessment): Knowledge
|
||||
const before = JSON.stringify(kb.entries);
|
||||
const byId = new Map(kb.entries.map((e) => [e.id, e]));
|
||||
const now = new Date().toISOString();
|
||||
// A merged-away name lives on as an alias; learning must land on the survivor,
|
||||
// or the next save would re-create the duplicate the curator just removed.
|
||||
const byName = nameIndex(kb.entries);
|
||||
|
||||
for (const c of a.candidates) {
|
||||
const label = candidateLabel(c);
|
||||
// Only capture entries a seller has actually filled in and mapped.
|
||||
if (!label || c.productIds.length === 0) continue;
|
||||
|
||||
const id = kbId(label);
|
||||
const id = byName.get(nameKey(label)) ?? kbId(label);
|
||||
let entry = byId.get(id);
|
||||
if (!entry) {
|
||||
entry = newEntry(label, "learned");
|
||||
@@ -241,3 +244,129 @@ export function searchKb(kb: KnowledgeBase, query: string, limit = 8): KbEntry[]
|
||||
.slice(0, limit)
|
||||
.map((x) => x.e);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ names and aliases */
|
||||
|
||||
const nameKey = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
/** Label and every alias → entry id. */
|
||||
function nameIndex(entries: KbEntry[]) {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of entries) {
|
||||
for (const n of [e.label, ...e.aliases]) if (!m.has(nameKey(n))) m.set(nameKey(n), e.id);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The entry a typed or picked tool name refers to, by label or alias. */
|
||||
export function findEntry(kb: KnowledgeBase | null, label: string): KbEntry | null {
|
||||
if (!kb || !label.trim()) return null;
|
||||
const k = nameKey(label);
|
||||
return kb.entries.find((e) => nameKey(e.label) === k || e.aliases.some((x) => nameKey(x) === k)) ?? null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ confidence */
|
||||
|
||||
export type Confidence = { level: "high" | "medium" | "low"; source: string; stale: boolean; overridden: boolean };
|
||||
|
||||
const STALE_MS = 365 * 24 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* How far to trust an entry's defaults: 5+ deals is high, 2–4 or curated is
|
||||
* medium, one deal or catalog-seeded is low. A curator can override it; an
|
||||
* entry not reviewed in 12 months is flagged stale.
|
||||
*/
|
||||
export function confidenceOf(e: KbEntry, now = Date.now()): Confidence {
|
||||
const deals = new Set(e.observations.map((o) => o.assessmentId)).size;
|
||||
const source = deals > 0
|
||||
? `Median of ${deals} deal${deals === 1 ? "" : "s"}${e.pinned ? ", curated" : ""}`
|
||||
: e.pinned ? `Curated${e.owner ? ` by ${e.owner}` : ""}` : "Seeded from the product catalog";
|
||||
const computed: Confidence["level"] = deals >= 5 ? "high" : deals >= 2 || e.pinned ? "medium" : "low";
|
||||
const reviewed = e.reviewedAt ? Date.parse(e.reviewedAt) : NaN;
|
||||
return {
|
||||
level: e.confidenceOverride ?? computed,
|
||||
source,
|
||||
stale: (e.pinned || !!e.reviewedAt) && (isNaN(reviewed) || now - reviewed > STALE_MS),
|
||||
overridden: !!e.confidenceOverride,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ duplicates and merge */
|
||||
|
||||
const GENERIC = new Set(["inc", "software", "platform", "suite", "enterprise", "security", "cloud", "the"]);
|
||||
const tokens = (s: string) => nameKey(s.replace(/\(.*?\)/g, " ")).split(" ").filter((t) => t && !GENERIC.has(t));
|
||||
|
||||
export type DuplicatePair = { a: KbEntry; b: KbEntry; reason: string };
|
||||
|
||||
export const pairKey = (x: string, y: string) => [x, y].sort().join("|");
|
||||
|
||||
/**
|
||||
* Entries that look like the same tool: identical once generic words are
|
||||
* dropped, one name contained in the other (same vendor), or a shared alias.
|
||||
* Nothing is merged automatically — a curator decides.
|
||||
*/
|
||||
export function findDuplicates(kb: KnowledgeBase): DuplicatePair[] {
|
||||
const distinct = new Set(kb.distinctPairs ?? []);
|
||||
const out: DuplicatePair[] = [];
|
||||
const list = kb.entries.map((e) => ({ e, t: tokens(e.label), names: new Set([e.label, ...e.aliases].map(nameKey)) }));
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const x = list[i], y = list[j];
|
||||
if (distinct.has(pairKey(x.e.id, y.e.id))) continue;
|
||||
if (x.e.domain && y.e.domain && x.e.domain !== y.e.domain) continue;
|
||||
let reason = "";
|
||||
if (x.t.length && x.t.join(" ") === y.t.join(" ")) reason = "Same name once generic words are ignored";
|
||||
else if ([...x.names].some((n) => y.names.has(n))) reason = "Share a name or alias";
|
||||
else if (x.t[0] && x.t[0] === y.t[0] && x.t.length !== y.t.length) {
|
||||
const [short, long] = x.t.length < y.t.length ? [x.t, y.t] : [y.t, x.t];
|
||||
if (short.every((t, k) => long[k] === t)) reason = "One name extends the other";
|
||||
}
|
||||
if (reason) out.push({ a: x.e, b: y.e, reason });
|
||||
}
|
||||
}
|
||||
// Pairs with deal history first — those are the ones splitting learned data.
|
||||
return out.sort((p, q) => (q.a.observations.length + q.b.observations.length) - (p.a.observations.length + p.b.observations.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds `dropId` into `keepId`: its name and aliases become aliases, its deal
|
||||
* history moves over, and gaps in the survivor are filled from it.
|
||||
*/
|
||||
export function mergeEntries(kb: KnowledgeBase, keepId: string, dropId: string): KnowledgeBase {
|
||||
const keep = kb.entries.find((e) => e.id === keepId);
|
||||
const drop = kb.entries.find((e) => e.id === dropId);
|
||||
if (!keep || !drop || keep === drop) return kb;
|
||||
|
||||
const seen = new Set([nameKey(keep.label)]);
|
||||
const aliases: string[] = [];
|
||||
for (const n of [...keep.aliases, drop.label, ...drop.aliases]) {
|
||||
if (seen.has(nameKey(n))) continue;
|
||||
seen.add(nameKey(n));
|
||||
aliases.push(n);
|
||||
}
|
||||
const obs = new Map(keep.observations.map((o) => [o.sourceKey, o]));
|
||||
for (const o of drop.observations) {
|
||||
const cur = obs.get(o.sourceKey);
|
||||
if (!cur || cur.recordedAt < o.recordedAt) obs.set(o.sourceKey, o);
|
||||
}
|
||||
|
||||
const merged = recomputeDefaults({
|
||||
...keep,
|
||||
aliases,
|
||||
observations: [...obs.values()],
|
||||
domain: keep.domain || drop.domain,
|
||||
productIds: keep.productIds.length ? keep.productIds : drop.productIds,
|
||||
unitLabel: keep.unitLabel || drop.unitLabel,
|
||||
risks: keep.risks || drop.risks,
|
||||
notes: [keep.notes, drop.notes].filter(Boolean).join("\n\n"),
|
||||
tco: keep.tco ?? drop.tco,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
...kb,
|
||||
entries: kb.entries.filter((e) => e.id !== dropId).map((e) => (e.id === keepId ? merged : e)),
|
||||
distinctPairs: (kb.distinctPairs ?? []).filter((p) => !p.split("|").includes(dropId)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
77
src/lib/portfolio.ts
Normal file
77
src/lib/portfolio.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Assessment, Catalog, DealStatus } from "./types";
|
||||
import { evaluate, findScenario } from "./finance";
|
||||
import { candidateLabel } from "./kb";
|
||||
|
||||
export type PortfolioRow = {
|
||||
id: string; customerName: string; status: DealStatus; scenario: string;
|
||||
netAnnual: number; horizonNet: number; npv: number; horizonYears: number;
|
||||
tco: number; toolsBefore: number; toolsAfter: number; vendorsBefore: number; updatedAt: string;
|
||||
};
|
||||
|
||||
export type Portfolio = {
|
||||
rows: PortfolioRow[];
|
||||
/** Everything except lost deals. */
|
||||
totals: { accounts: number; netAnnual: number; horizonNet: number; tco: number; tools: number; vendors: number };
|
||||
byStatus: { status: DealStatus; accounts: number; netAnnual: number }[];
|
||||
byBu: { buId: string; name: string; color: string; savings: number; accounts: number }[];
|
||||
competitors: { label: string; accounts: number; lines: number; tco: number }[];
|
||||
};
|
||||
|
||||
export const STATUS_LABEL: Record<DealStatus, string> = { open: "Open", won: "Won", lost: "Lost", paused: "Paused" };
|
||||
|
||||
/** Each assessment is valued as it is presented to the customer (its presented scenario). */
|
||||
export function buildPortfolio(list: Assessment[], catalog: Catalog, today = new Date()): Portfolio {
|
||||
const rows: PortfolioRow[] = [];
|
||||
const bu = new Map<string, { buId: string; name: string; color: string; savings: number; accounts: Set<string> }>();
|
||||
const comp = new Map<string, { label: string; accounts: Set<string>; lines: number; tco: number }>();
|
||||
const status = new Map<DealStatus, { accounts: number; netAnnual: number }>();
|
||||
const totals = { accounts: 0, netAnnual: 0, horizonNet: 0, tco: 0, tools: 0, vendors: 0 };
|
||||
|
||||
for (const a of list) {
|
||||
const e = evaluate(a, catalog, findScenario(a, a.presentedScenario), today);
|
||||
const st: DealStatus = a.status ?? "open";
|
||||
rows.push({
|
||||
id: a.id, customerName: a.customerName, status: st, scenario: e.scenario.name,
|
||||
netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, npv: e.cash.npv, horizonYears: a.horizonYears,
|
||||
tco: e.r.addressableTco, toolsBefore: e.r.toolsRetired, toolsAfter: e.r.toolsAfter,
|
||||
vendorsBefore: e.r.vendorsRetired, updatedAt: a.updatedAt,
|
||||
});
|
||||
|
||||
const s = status.get(st) ?? { accounts: 0, netAnnual: 0 };
|
||||
s.accounts++; s.netAnnual += e.cash.runRateNet;
|
||||
status.set(st, s);
|
||||
if (st === "lost") continue;
|
||||
|
||||
totals.accounts++;
|
||||
totals.netAnnual += e.cash.runRateNet;
|
||||
totals.horizonNet += e.cash.horizonNet;
|
||||
totals.tco += e.r.addressableTco;
|
||||
totals.tools += e.r.toolsRetired;
|
||||
totals.vendors += e.r.vendorsRetired;
|
||||
|
||||
for (const b of e.r.byBu) {
|
||||
const x = bu.get(b.buId) ?? { buId: b.buId, name: b.name, color: b.color, savings: 0, accounts: new Set<string>() };
|
||||
x.savings += b.savings; x.accounts.add(a.id);
|
||||
bu.set(b.buId, x);
|
||||
}
|
||||
for (const c of e.r.included) {
|
||||
const label = candidateLabel(c);
|
||||
if (!label) continue;
|
||||
const k = label.toLowerCase();
|
||||
const x = comp.get(k) ?? { label, accounts: new Set<string>(), lines: 0, tco: 0 };
|
||||
x.accounts.add(a.id); x.lines++; x.tco += e.r.metrics.get(c.id)!.tco;
|
||||
comp.set(k, x);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows: rows.sort((x, y) => y.netAnnual - x.netAnnual),
|
||||
totals,
|
||||
byStatus: (["open", "won", "paused", "lost"] as DealStatus[]).map((st) => ({ status: st, ...(status.get(st) ?? { accounts: 0, netAnnual: 0 }) })),
|
||||
byBu: [...bu.values()].map((x) => ({ ...x, accounts: x.accounts.size })).sort((x, y) => y.savings - x.savings),
|
||||
competitors: [...comp.values()]
|
||||
.map((x) => ({ label: x.label, accounts: x.accounts.size, lines: x.lines, tco: x.tco }))
|
||||
.sort((x, y) => y.accounts - x.accounts || y.tco - x.tco)
|
||||
.slice(0, 15),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Assessment, Candidate, Catalog, Weights } from "./types";
|
||||
import type { Assessment, Candidate, Catalog, Finance, TcoDefaults, Weights } from "./types";
|
||||
|
||||
export const DEFAULT_WEIGHTS: Weights = {
|
||||
capability: 35,
|
||||
@@ -7,6 +7,28 @@ export const DEFAULT_WEIGHTS: Weights = {
|
||||
spend: 15,
|
||||
};
|
||||
|
||||
/** TCO baseline for a line with no knowledge-base entry behind it. */
|
||||
export const TCO_FALLBACK: TcoDefaults = { infraPct: 15, adminFte: 0.25, otherPct: 5 };
|
||||
|
||||
export const FINANCE_DEFAULTS: Finance = {
|
||||
discountRate: 10,
|
||||
fteCost: 150_000,
|
||||
investment: { annual: 0, services: 0 },
|
||||
};
|
||||
|
||||
/** Fills gaps so older assessments without a finance block still compute. */
|
||||
export function financeOf(a: Pick<Assessment, "finance">): Finance {
|
||||
const f = a.finance;
|
||||
return {
|
||||
discountRate: f?.discountRate ?? FINANCE_DEFAULTS.discountRate,
|
||||
fteCost: f?.fteCost ?? FINANCE_DEFAULTS.fteCost,
|
||||
investment: {
|
||||
annual: Math.max(0, f?.investment?.annual ?? 0),
|
||||
services: Math.max(0, f?.investment?.services ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -14,7 +36,48 @@ export function roiNorm(months: number) {
|
||||
return clamp01(1 - (months - 6) / 30);
|
||||
}
|
||||
|
||||
export type CandidateMetrics = {
|
||||
/* ------------------------------------------------------------ line cost */
|
||||
|
||||
export type LineCost = {
|
||||
licence: number;
|
||||
infra: number;
|
||||
adminFte: number;
|
||||
admin: number;
|
||||
other: number;
|
||||
/** Full annual cost of the incumbent: licence + infra + admin + other. */
|
||||
tco: number;
|
||||
/** Which of infra / admin / other the seller has overridden. */
|
||||
overridden: { infra: boolean; adminFte: boolean; other: boolean };
|
||||
};
|
||||
|
||||
export function lineCost(c: Candidate, fteCost: number): LineCost {
|
||||
const d = c.tcoDefaults ?? TCO_FALLBACK;
|
||||
const licence = Math.max(0, c.annualSpend);
|
||||
const o = c.tco;
|
||||
const infra = Math.max(0, o?.infra ?? (licence * d.infraPct) / 100);
|
||||
// An unknown spend is an unknown tool: no phantom admin cost until spend is captured.
|
||||
const adminFte = Math.max(0, o?.adminFte ?? (licence > 0 ? d.adminFte : 0));
|
||||
const admin = adminFte * fteCost;
|
||||
const other = Math.max(0, o?.other ?? (licence * d.otherPct) / 100);
|
||||
return {
|
||||
licence, infra, adminFte, admin, other,
|
||||
tco: licence + infra + admin + other,
|
||||
overridden: { infra: o?.infra != null, adminFte: o?.adminFte != null, other: o?.other != null },
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ metrics */
|
||||
|
||||
export type CandidateMetrics = LineCost & {
|
||||
/** Cost removed from the customer each year once this line is retired. */
|
||||
grossRemoved: number;
|
||||
/** Our subscription attributed to this line (0 when no investment is recorded). */
|
||||
subscription: number;
|
||||
/** Services attributed to this line. */
|
||||
services: number;
|
||||
/** One-time cost: migration plus attributed services. */
|
||||
oneTime: number;
|
||||
/** Net annual benefit at run-rate: gross removed less our subscription. */
|
||||
annualSavings: number;
|
||||
horizonGross: number;
|
||||
horizonNet: number;
|
||||
@@ -24,13 +87,32 @@ export type CandidateMetrics = {
|
||||
wave: 1 | 2 | 3;
|
||||
};
|
||||
|
||||
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 type Rollup = {
|
||||
metrics: Map<string, CandidateMetrics>;
|
||||
included: Candidate[];
|
||||
totalAnnualSpend: number;
|
||||
/** Licence spend of included lines. */
|
||||
addressableSpend: number;
|
||||
/** Full TCO of included lines. */
|
||||
addressableTco: number;
|
||||
/** Gross cost removed per year at run-rate. */
|
||||
totalGrossRemoved: number;
|
||||
/** Our annual subscription, deal-level (internal). */
|
||||
totalSubscription: number;
|
||||
/** Net annual benefit at run-rate. */
|
||||
totalAnnualSavings: number;
|
||||
/** One-time cost: migration plus services. */
|
||||
totalMigrationCost: number;
|
||||
/** Whether our investment has been recorded; switches the model from savings % to gross − investment. */
|
||||
hasInvestment: boolean;
|
||||
horizonGross: number;
|
||||
horizonNet: number;
|
||||
blendedPaybackMonths: number | null;
|
||||
@@ -45,83 +127,113 @@ export type Rollup = {
|
||||
unmappedCount: number;
|
||||
byWave: Record<1 | 2 | 3, Candidate[]>;
|
||||
byBu: BuValue[];
|
||||
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number }[];
|
||||
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number; vendors: 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 {
|
||||
function scoreOf(c: Candidate, weights: Weights, cost: number, maxCost: number) {
|
||||
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 spendN = maxCost > 0 ? clamp01(Math.sqrt(Math.max(0, cost)) / Math.sqrt(maxCost)) : 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 };
|
||||
return (100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum;
|
||||
}
|
||||
|
||||
export function rollup(a: Assessment, catalog: Catalog): Rollup {
|
||||
/**
|
||||
* Per-line economics and the assessment rollup.
|
||||
*
|
||||
* Two models, by whether our investment is recorded:
|
||||
* - Without it, each line's net benefit is its savings % of full TCO (the
|
||||
* phase-1 model, now applied to TCO rather than licence alone).
|
||||
* - With it, each line removes its full TCO and carries a share of our
|
||||
* subscription. A line's own pricing wins; the rest of the deal-level figure
|
||||
* is spread across mapped lines by TCO. Lines already entitled carry none.
|
||||
*
|
||||
* `valueFactor` (from a scenario) scales the cost each line removes.
|
||||
*/
|
||||
export function rollup(a: Assessment, catalog: Catalog, valueFactor = 1): Rollup {
|
||||
const f = financeOf(a);
|
||||
const included = a.candidates.filter((c) => c.included);
|
||||
const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend));
|
||||
const costs = new Map(a.candidates.map((c) => [c.id, lineCost(c, f.fteCost)]));
|
||||
const maxCost = Math.max(0, ...included.map((c) => costs.get(c.id)!.tco));
|
||||
|
||||
const hasInvestment = f.investment.annual > 0 || f.investment.services > 0
|
||||
|| a.candidates.some((c) => c.investment?.annual != null || c.investment?.services != null);
|
||||
|
||||
// Deal-level remainders, shared across included, mapped lines with no price of their own.
|
||||
const shareable = included.filter((c) => c.productIds.length > 0);
|
||||
const explicitAnnual = shareable.reduce((s, c) => s + (c.investment?.annual ?? 0), 0);
|
||||
const explicitServices = shareable.reduce((s, c) => s + (c.investment?.services ?? 0), 0);
|
||||
const annualPool = Math.max(0, f.investment.annual - explicitAnnual);
|
||||
const servicesPool = Math.max(0, f.investment.services - explicitServices);
|
||||
const takesAnnual = shareable.filter((c) => c.investment?.annual == null && !c.alreadyEntitled);
|
||||
const takesServices = shareable.filter((c) => c.investment?.services == null);
|
||||
const weightOf = (list: Candidate[]) => {
|
||||
const total = list.reduce((s, c) => s + costs.get(c.id)!.tco, 0);
|
||||
return (c: Candidate) => (total > 0 ? costs.get(c.id)!.tco / total : 1 / list.length);
|
||||
};
|
||||
const wA = weightOf(takesAnnual), wS = weightOf(takesServices);
|
||||
|
||||
const metrics = new Map<string, CandidateMetrics>();
|
||||
for (const c of a.candidates) {
|
||||
metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears));
|
||||
const cost = costs.get(c.id)!;
|
||||
const mapped = c.productIds.length > 0 && c.included;
|
||||
const subscription = !hasInvestment || !mapped ? 0
|
||||
: c.investment?.annual ?? (takesAnnual.includes(c) ? annualPool * wA(c) : 0);
|
||||
const services = !hasInvestment || !mapped ? 0
|
||||
: c.investment?.services ?? (takesServices.includes(c) ? servicesPool * wS(c) : 0);
|
||||
const grossRemoved = hasInvestment
|
||||
? cost.tco * valueFactor
|
||||
: (cost.tco * c.savingsPct * valueFactor) / 100;
|
||||
const annualSavings = grossRemoved - subscription;
|
||||
const oneTime = c.migrationCost + services;
|
||||
const horizonGross = annualSavings * a.horizonYears;
|
||||
const horizonNet = horizonGross - oneTime;
|
||||
const paybackMonths = annualSavings > 0 ? (oneTime / annualSavings) * 12 : oneTime > 0 ? null : 0;
|
||||
const roiPct = oneTime > 0 ? (horizonNet / oneTime) * 100 : null;
|
||||
const score = scoreOf(c, a.weights, cost.tco, maxCost);
|
||||
const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3;
|
||||
metrics.set(c.id, {
|
||||
...cost, grossRemoved, subscription, services, oneTime, annualSavings,
|
||||
horizonGross, horizonNet, paybackMonths, roiPct, score, wave,
|
||||
});
|
||||
}
|
||||
|
||||
const sum = (fn: (m: CandidateMetrics, c: Candidate) => number) =>
|
||||
included.reduce((s, c) => s + fn(metrics.get(c.id)!, c), 0);
|
||||
|
||||
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 addressableSpend = sum((_m, c) => c.annualSpend);
|
||||
const addressableTco = sum((m) => m.tco);
|
||||
const totalGrossRemoved = sum((m) => m.grossRemoved);
|
||||
const lineSubscriptions = sum((m) => m.subscription);
|
||||
// An entitled-only deal can leave part of the pool unattributed; it is still owed.
|
||||
const totalSubscription = hasInvestment ? Math.max(lineSubscriptions, f.investment.annual) : 0;
|
||||
const totalAnnualSavings = totalGrossRemoved - totalSubscription;
|
||||
const totalServices = hasInvestment ? Math.max(sum((m) => m.services), f.investment.services) : 0;
|
||||
const totalMigrationCost = sum((_m, c) => c.migrationCost) + totalServices;
|
||||
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 blendedPaybackMonths = totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null;
|
||||
const avgScore = included.length ? sum((m) => m.score) / 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.
|
||||
// Value is split evenly across the mapped BUs so BU credit sums to the total.
|
||||
const buAgg = new Map<string, { savings: number; spend: number; migrationCost: number; count: number; products: Set<string> }>();
|
||||
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<string>() };
|
||||
e.savings += m.annualSavings / bus.length;
|
||||
e.spend += c.annualSpend / bus.length;
|
||||
e.migrationCost += c.migrationCost / bus.length;
|
||||
e.spend += m.tco / bus.length;
|
||||
e.migrationCost += m.oneTime / 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);
|
||||
@@ -140,42 +252,48 @@ export function rollup(a: Assessment, catalog: Catalog): Rollup {
|
||||
})
|
||||
.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<string, { savings: number; spend: number; count: number }>();
|
||||
const domAgg = new Map<string, { savings: number; spend: number; count: number; vendors: Set<string> }>();
|
||||
for (const c of included) {
|
||||
const m = metrics.get(c.id)!;
|
||||
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 };
|
||||
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0, vendors: new Set<string>() };
|
||||
e.savings += m.annualSavings;
|
||||
e.spend += c.annualSpend;
|
||||
e.spend += m.tco;
|
||||
e.count += 1;
|
||||
if (vendorKey(c)) e.vendors.add(vendorKey(c));
|
||||
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)! }))
|
||||
.map((d) => {
|
||||
const e = domAgg.get(d.id)!;
|
||||
return { domainId: d.id, name: d.name, savings: e.savings, spend: e.spend, count: e.count, vendors: e.vendors.size };
|
||||
})
|
||||
.sort((x, y) => y.savings - x.savings);
|
||||
|
||||
const mapped = included.filter((c) => c.productIds.length);
|
||||
const unmapped = included.filter((c) => !c.productIds.length);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
included,
|
||||
totalAnnualSpend,
|
||||
addressableSpend,
|
||||
addressableTco,
|
||||
totalGrossRemoved,
|
||||
totalSubscription,
|
||||
totalAnnualSavings,
|
||||
totalMigrationCost,
|
||||
hasInvestment,
|
||||
horizonGross,
|
||||
horizonNet,
|
||||
blendedPaybackMonths,
|
||||
savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0,
|
||||
savingsPct: addressableTco > 0 ? (totalAnnualSavings / addressableTco) * 100 : 0,
|
||||
avgScore,
|
||||
toolsRetired: included.length,
|
||||
vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size,
|
||||
toolsAfter,
|
||||
vendorsAfter,
|
||||
vendorsRetired: new Set(included.map(vendorKey).filter(Boolean)).size,
|
||||
toolsAfter: new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length,
|
||||
vendorsAfter: (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size,
|
||||
unmappedCount: unmapped.length,
|
||||
byWave,
|
||||
byDomain,
|
||||
|
||||
77
src/lib/shelfware.ts
Normal file
77
src/lib/shelfware.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Assessment, Catalog, IntelConsumption, Product } from "./types";
|
||||
|
||||
/**
|
||||
* Shelfware: products the customer already owns but has not fully deployed.
|
||||
*
|
||||
* The dossier names them by short code ("VSAN", "RALLY", "UIM"), so each is
|
||||
* matched to a catalog product by exact name, then whole-word containment,
|
||||
* then acronym ("UIM" → "Unified Infrastructure Management").
|
||||
*/
|
||||
|
||||
const BRAND_WORDS = new Set(["vmware", "broadcom", "dx", "symantec", "ca", "vcf"]);
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
function acronym(name: string) {
|
||||
return norm(name.replace(/\(.*?\)/g, ""))
|
||||
.split(" ")
|
||||
.filter((w) => w && !BRAND_WORDS.has(w) && !["for", "and", "of", "the"].includes(w))
|
||||
.map((w) => w[0])
|
||||
.join("");
|
||||
}
|
||||
|
||||
const exactMatches = (code: string, catalog: Catalog) => {
|
||||
const c = norm(code);
|
||||
return c ? catalog.products.filter((p) => norm(p.name) === c || norm(p.name.replace(/^vmware /i, "")) === c) : [];
|
||||
};
|
||||
|
||||
/** Every catalog product the code could mean, best match first. */
|
||||
export function productMatches(code: string, catalog: Catalog): Product[] {
|
||||
const c = norm(code);
|
||||
if (!c) return [];
|
||||
const products = catalog.products;
|
||||
const exact = exactMatches(code, catalog);
|
||||
const word = new RegExp(`(^| )${c}( |$)`);
|
||||
const contains = products.filter((p) => word.test(norm(p.name))).sort((x, y) => x.name.length - y.name.length);
|
||||
const compact = c.replace(/ /g, "");
|
||||
const acro = compact.length >= 2 ? products.filter((p) => acronym(p.name) === compact) : [];
|
||||
return [...new Set([...exact, ...contains, ...acro])];
|
||||
}
|
||||
|
||||
export function matchProduct(code: string, catalog: Catalog): Product | null {
|
||||
return productMatches(code, catalog)[0] ?? null;
|
||||
}
|
||||
|
||||
export type ShelfItem = IntelConsumption & {
|
||||
product: string;
|
||||
match: Product | null;
|
||||
unused: number;
|
||||
/** Candidate ids whose replacement uses this entitlement. */
|
||||
linked: string[];
|
||||
};
|
||||
|
||||
/** Entitled-but-undeployed products and the displacements that could use them. */
|
||||
export function shelfware(a: Assessment, catalog: Catalog): ShelfItem[] {
|
||||
const rows = a.accountIntel?.consumption ?? [];
|
||||
return rows
|
||||
.filter((x) => x.entitled > x.deployed)
|
||||
.map((x) => {
|
||||
// An exact name is decisive. Otherwise a code can fit several products
|
||||
// ("UIM"), and the one this plan actually uses wins.
|
||||
const options = productMatches(x.product, catalog);
|
||||
const using = (p: Product) => a.candidates.filter((c) => c.included && c.productIds.includes(p.id)).map((c) => c.id);
|
||||
const match = exactMatches(x.product, catalog)[0] ?? options.find((p) => using(p).length) ?? options[0] ?? null;
|
||||
return { ...x, match, unused: x.entitled - x.deployed, linked: match ? using(match) : [] };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The entitlement each candidate can draw on, as the product name to record on
|
||||
* the line (candidate.alreadyEntitled). Absent = none.
|
||||
*/
|
||||
export function entitlementFlags(a: Assessment, catalog: Catalog): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const s of shelfware(a, catalog)) {
|
||||
for (const id of s.linked) if (!out.has(id)) out.set(id, s.match!.name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
|
||||
import type { Assessment, AssessmentSummary, Branding, Catalog, KnowledgeBase, Snapshot, SnapshotMeta, Survey } from "./types";
|
||||
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
|
||||
import { evaluate } from "./finance";
|
||||
import { forgetAssessment, learnFromAssessment } from "./kb";
|
||||
import { isSurveyToken } from "./survey";
|
||||
|
||||
@@ -11,6 +12,8 @@ const CATALOG_PATH = path.join(DATA_DIR, "catalog.json");
|
||||
const KB_PATH = path.join(DATA_DIR, "knowledge-base.json");
|
||||
const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments");
|
||||
const SURVEY_DIR = path.join(DATA_DIR, "surveys");
|
||||
const SNAPSHOT_DIR = path.join(DATA_DIR, "snapshots");
|
||||
const SETTINGS_PATH = path.join(DATA_DIR, "settings.json");
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(file, "utf8")) as T;
|
||||
@@ -73,6 +76,7 @@ export async function listAssessments(): Promise<AssessmentSummary[]> {
|
||||
preparedBy: a.preparedBy,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
status: a.status ?? "open",
|
||||
candidateCount: a.candidates.length,
|
||||
annualSpend: r.addressableSpend,
|
||||
annualSavings: r.totalAnnualSavings,
|
||||
@@ -114,6 +118,7 @@ export async function deleteAssessment(id: string) {
|
||||
await fs.rm(assessmentPath(id), { force: true });
|
||||
|
||||
for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true });
|
||||
await fs.rm(path.join(SNAPSHOT_DIR, id), { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
const kb = await getKnowledgeBase();
|
||||
@@ -236,3 +241,71 @@ export async function mutateSurvey(id: string, fn: (s: Survey) => void | string)
|
||||
export async function deleteSurvey(id: string) {
|
||||
await fs.rm(surveyPath(id), { force: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ branding */
|
||||
|
||||
export const DEFAULT_BRANDING: Branding = { companyName: "", accent: "#005C8A", logo: "" };
|
||||
|
||||
export async function getBranding(): Promise<Branding> {
|
||||
try {
|
||||
return { ...DEFAULT_BRANDING, ...(await readJson<Partial<Branding>>(SETTINGS_PATH)) };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRANDING };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBranding(b: Branding): Promise<Branding> {
|
||||
await writeJson(SETTINGS_PATH, b);
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ snapshots */
|
||||
|
||||
const snapshotDir = (assessmentId: string) => {
|
||||
if (!isSafeId(assessmentId)) throw new Error(`Invalid assessment id: ${assessmentId}`);
|
||||
return path.join(SNAPSHOT_DIR, assessmentId);
|
||||
};
|
||||
|
||||
export async function listSnapshots(assessmentId: string): Promise<SnapshotMeta[]> {
|
||||
const dir = snapshotDir(assessmentId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const out: SnapshotMeta[] = [];
|
||||
for (const f of await fs.readdir(dir)) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const { assessment: _a, ...meta } = await readJson<Snapshot>(path.join(dir, f));
|
||||
out.push(meta);
|
||||
} catch {
|
||||
// Skip a damaged snapshot rather than hiding the rest.
|
||||
}
|
||||
}
|
||||
return out.sort((x, y) => y.createdAt.localeCompare(x.createdAt));
|
||||
}
|
||||
|
||||
export async function getSnapshot(assessmentId: string, id: string): Promise<Snapshot | null> {
|
||||
if (!/^[a-z0-9-]{1,40}$/.test(id)) return null;
|
||||
try {
|
||||
return await readJson<Snapshot>(path.join(snapshotDir(assessmentId), `${id}.json`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Freezes the assessment as it stands, with its headline, under a label. */
|
||||
export async function createSnapshot(a: Assessment, label: string, trigger: SnapshotMeta["trigger"]): Promise<SnapshotMeta> {
|
||||
const catalog = await getCatalog();
|
||||
const e = evaluate(a, catalog);
|
||||
const createdAt = new Date().toISOString();
|
||||
const snap: Snapshot = {
|
||||
id: `${createdAt.replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`,
|
||||
assessmentId: a.id,
|
||||
label: label.trim().slice(0, 120) || "Snapshot",
|
||||
trigger,
|
||||
createdAt,
|
||||
headline: { netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, tools: e.r.toolsRetired },
|
||||
assessment: structuredClone(a),
|
||||
};
|
||||
await writeJson(path.join(snapshotDir(a.id), `${snap.id}.json`), snap);
|
||||
const { assessment: _a, ...meta } = snap;
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Assessment, Catalog } from "./types";
|
||||
import type { Assessment, Candidate, Catalog } from "./types";
|
||||
import type { Rollup } from "./scoring";
|
||||
|
||||
/**
|
||||
@@ -7,15 +7,16 @@ import type { Rollup } from "./scoring";
|
||||
* first full month after today), and a project occupies [start, end).
|
||||
*
|
||||
* Rules, in order:
|
||||
* 0. A seller's pinned start and duration always win.
|
||||
* 1. A tool with a known contract end is scheduled to cut over by that date,
|
||||
* so the customer does not renew it. If there is no longer time to do so,
|
||||
* it starts now and is flagged as needing a short bridge extension.
|
||||
* it starts as early as it can and is flagged as needing a bridge extension.
|
||||
* 2. Everything else is sequenced into the earliest month its workstream has
|
||||
* capacity: lines with a known saving before those without (spend not yet
|
||||
* captured), then by wave (quick wins first), then by size of saving. Each
|
||||
* business unit is its own workstream (security, infrastructure and ops
|
||||
* migrations are run by different teams), with a set number in parallel.
|
||||
* 3. Savings start at cutover, or at contract end if that comes later —
|
||||
* captured), then by wave (quick wins first), then by size of saving.
|
||||
* Each business unit is its own workstream, with a set number in parallel.
|
||||
* 3. "Starts after" dependencies hold for everything that is not pinned.
|
||||
* 4. Savings start at cutover, or at contract end if that comes later —
|
||||
* the customer keeps paying for a contract until it expires.
|
||||
*/
|
||||
|
||||
@@ -25,6 +26,7 @@ export const DURATION_BY_EASE: Record<number, number> = { 5: 2, 4: 3, 3: 5, 2: 8
|
||||
export const DEFAULT_PARALLEL = 2;
|
||||
/** Earliest month each wave may start. */
|
||||
const WAVE_OFFSET: Record<1 | 2 | 3, number> = { 1: 0, 2: 3, 3: 6 };
|
||||
const MAX_MONTH = 240;
|
||||
|
||||
export type PlanItem = {
|
||||
id: string;
|
||||
@@ -38,11 +40,20 @@ export type PlanItem = {
|
||||
/** Month the contract runs out (the bar position is the end of that month), null if unknown. */
|
||||
contractEnd: number | null;
|
||||
savingsFrom: number;
|
||||
/** Net annual benefit of the line (after its share of our subscription). */
|
||||
annualSavings: number;
|
||||
/** Cost removed from the customer each year once retired. */
|
||||
grossRemoved: number;
|
||||
/** One-time cost: migration plus attributed services. */
|
||||
migrationCost: number;
|
||||
anchor: "contract" | "sequenced";
|
||||
anchor: "contract" | "sequenced" | "pinned";
|
||||
/** Contract ends before the migration can finish. */
|
||||
bridge: boolean;
|
||||
/** A pinned start sits before a predecessor finishes. */
|
||||
conflict: boolean;
|
||||
/** Seller has overridden start, duration or dependencies. */
|
||||
edited: boolean;
|
||||
after: string[];
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
@@ -53,19 +64,24 @@ export type Plan = {
|
||||
completion: number;
|
||||
/** Month full run-rate savings are reached. */
|
||||
fullRunRate: number;
|
||||
/** Annual run-rate savings in effect during each month, 0..span-1. */
|
||||
/** Annual run-rate net savings in effect during each month, 0..span-1. */
|
||||
ramp: number[];
|
||||
span: number;
|
||||
/** Savings actually realised inside the planning horizon once ramp-up is counted. */
|
||||
/** Net savings realised inside the planning horizon once ramp-up is counted. */
|
||||
realizedInHorizon: number;
|
||||
/** The same horizon if every saving started on day one — the flat model the headline uses. */
|
||||
/** The same horizon if every saving started on day one — the flat model. */
|
||||
flatInHorizon: number;
|
||||
bridgeCount: number;
|
||||
conflictCount: number;
|
||||
unmappedCount: number;
|
||||
};
|
||||
|
||||
export function planStart(today = new Date()) {
|
||||
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
|
||||
}
|
||||
|
||||
const monthIndex = (start: Date, iso: string) => {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
const d = new Date(`${iso.length === 7 ? `${iso}-01` : iso}T00:00:00Z`);
|
||||
if (isNaN(+d)) return null;
|
||||
return (d.getUTCFullYear() - start.getUTCFullYear()) * 12 + (d.getUTCMonth() - start.getUTCMonth());
|
||||
};
|
||||
@@ -75,6 +91,11 @@ export function monthDate(startIso: string, n: number) {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
|
||||
}
|
||||
|
||||
/** Month n as yyyy-mm, the form a pinned start is stored in. */
|
||||
export function monthKey(startIso: string, n: number) {
|
||||
return monthDate(startIso, n).toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function monthLabel(startIso: string, n: number, long = false) {
|
||||
return monthDate(startIso, n).toLocaleDateString("en-US", { month: long ? "long" : "short", year: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
@@ -84,70 +105,121 @@ export function quarterLabel(startIso: string, n: number) {
|
||||
return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
export function defaultDuration(c: Pick<Candidate, "switchEase">) {
|
||||
return DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5;
|
||||
}
|
||||
|
||||
export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = new Date()): Plan {
|
||||
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
|
||||
const start = planStart(today);
|
||||
const startIso = start.toISOString().slice(0, 10);
|
||||
|
||||
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
|
||||
const buById = new Map(catalog.businessUnits.map((b) => [b.id, b]));
|
||||
|
||||
const mapped = r.included.filter((c) => c.productIds.length);
|
||||
const mappedIds = new Set(mapped.map((c) => c.id));
|
||||
const parallel = Math.max(1, Math.round(a.planParallel ?? DEFAULT_PARALLEL));
|
||||
const load = new Map<string, number[]>();
|
||||
const track = (ws: string) => { if (!load.has(ws)) load.set(ws, []); return load.get(ws)!; };
|
||||
const occupy = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) l[m] = (l[m] ?? 0) + 1; };
|
||||
const fits = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) if ((l[m] ?? 0) >= parallel) return false; return true; };
|
||||
|
||||
const items: (PlanItem & { duration: number; workstream: string })[] = [];
|
||||
const base = (c: (typeof mapped)[number]) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
|
||||
return {
|
||||
id: c.id,
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
|
||||
color: bu?.color ?? "var(--text-muted)",
|
||||
bu: bu?.short ?? "",
|
||||
workstream: bu?.id ?? "other",
|
||||
wave: m.wave,
|
||||
annualSavings: m.annualSavings,
|
||||
migrationCost: c.migrationCost,
|
||||
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
|
||||
};
|
||||
type Work = {
|
||||
c: Candidate; ws: string; duration: number; after: string[];
|
||||
due: number | null; pinned: number | null;
|
||||
base: Omit<PlanItem, "start" | "end" | "savingsFrom" | "anchor" | "bridge" | "conflict">;
|
||||
};
|
||||
|
||||
// 1. Contract-anchored work is fixed first; it is dictated by the customer's paper, not our preference.
|
||||
const flexible: typeof mapped = [];
|
||||
for (const c of mapped) {
|
||||
const work: Work[] = mapped.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
|
||||
const ce = c.renewalDate ? monthIndex(start, c.renewalDate) : null;
|
||||
if (ce === null) { flexible.push(c); continue; }
|
||||
const b = base(c);
|
||||
const due = ce + 1; // cut over by the end of the contract month
|
||||
const bridge = due - b.duration < 0;
|
||||
const s = bridge ? 0 : due - b.duration;
|
||||
const e = s + b.duration;
|
||||
occupy(b.workstream, s, e);
|
||||
items.push({
|
||||
...b, start: s, end: e, contractEnd: ce, anchor: "contract", bridge,
|
||||
savingsFrom: Math.max(e, due),
|
||||
const pinnedRaw = c.plan?.start ? monthIndex(start, c.plan.start) : null;
|
||||
const after = (c.plan?.after ?? []).filter((id) => id !== c.id && mappedIds.has(id));
|
||||
return {
|
||||
c,
|
||||
ws: bu?.id ?? "other",
|
||||
duration: Math.max(1, Math.round(c.plan?.duration ?? defaultDuration(c))),
|
||||
after,
|
||||
due: ce === null ? null : ce + 1, // cut over by the end of the contract month
|
||||
pinned: pinnedRaw === null ? null : Math.max(0, pinnedRaw),
|
||||
base: {
|
||||
id: c.id,
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
|
||||
color: bu?.color ?? "var(--text-muted)",
|
||||
bu: bu?.short ?? "",
|
||||
wave: m.wave,
|
||||
contractEnd: ce,
|
||||
annualSavings: m.annualSavings,
|
||||
grossRemoved: m.grossRemoved,
|
||||
migrationCost: m.oneTime,
|
||||
edited: !!(c.plan?.start || c.plan?.duration || c.plan?.after?.length),
|
||||
after,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const placed = new Map<string, PlanItem>();
|
||||
const place = (w: Work, s: number, anchor: PlanItem["anchor"], bridge: boolean) => {
|
||||
const e = s + w.duration;
|
||||
occupy(w.ws, s, e);
|
||||
placed.set(w.c.id, {
|
||||
...w.base, start: s, end: e, anchor, bridge, conflict: false,
|
||||
savingsFrom: w.due === null ? e : Math.max(e, w.due),
|
||||
});
|
||||
};
|
||||
const depsEnd = (w: Work) => Math.max(0, ...w.after.map((id) => placed.get(id)?.end ?? 0));
|
||||
const depsReady = (w: Work) => w.after.every((id) => placed.has(id));
|
||||
|
||||
// 0. Pinned work is fixed first; it is the seller's decision.
|
||||
for (const w of work.filter((x) => x.pinned !== null)) {
|
||||
place(w, w.pinned!, "pinned", w.due !== null && w.pinned! + w.duration > w.due);
|
||||
}
|
||||
|
||||
// 2. The rest is sequenced into capacity: money first, then quick wins, then the biggest lines.
|
||||
flexible.sort((x, y) => {
|
||||
const mx = r.metrics.get(x.id)!, my = r.metrics.get(y.id)!;
|
||||
// 1–2. Contract-anchored work by due date, then the rest by value. A line waits
|
||||
// until everything it depends on is placed; a dependency cycle is broken by
|
||||
// ignoring the dependencies of whatever is left.
|
||||
const metric = (w: Work) => r.metrics.get(w.c.id)!;
|
||||
const queue = work.filter((w) => w.pinned === null).sort((x, y) => {
|
||||
if ((x.due === null) !== (y.due === null)) return x.due === null ? 1 : -1;
|
||||
if (x.due !== null && y.due !== null) return x.due - y.due;
|
||||
const mx = metric(x), my = metric(y);
|
||||
return Number(my.annualSavings > 0) - Number(mx.annualSavings > 0)
|
||||
|| mx.wave - my.wave || my.annualSavings - mx.annualSavings || my.score - mx.score;
|
||||
});
|
||||
for (const c of flexible) {
|
||||
const b = base(c);
|
||||
let s = WAVE_OFFSET[b.wave];
|
||||
while (!fits(b.workstream, s, s + b.duration) && s < 240) s++;
|
||||
occupy(b.workstream, s, s + b.duration);
|
||||
items.push({ ...b, start: s, end: s + b.duration, contractEnd: null, anchor: "sequenced", bridge: false, savingsFrom: s + b.duration });
|
||||
|
||||
let ignoreDeps = false;
|
||||
while (queue.length) {
|
||||
const i = queue.findIndex((w) => ignoreDeps || depsReady(w));
|
||||
if (i === -1) { ignoreDeps = true; continue; }
|
||||
const w = queue.splice(i, 1)[0];
|
||||
const earliest = ignoreDeps ? 0 : depsEnd(w);
|
||||
|
||||
if (w.due !== null) {
|
||||
const ideal = w.due - w.duration;
|
||||
if (ideal >= earliest) {
|
||||
place(w, ideal, "contract", false);
|
||||
} else {
|
||||
let s = earliest;
|
||||
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
|
||||
place(w, s, "contract", s + w.duration > w.due);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let s = Math.max(earliest, WAVE_OFFSET[metric(w).wave]);
|
||||
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
|
||||
place(w, s, "sequenced", false);
|
||||
}
|
||||
|
||||
items.sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
|
||||
// A pinned line that starts before a predecessor finishes is flagged, not moved.
|
||||
for (const w of work) {
|
||||
const it = placed.get(w.c.id)!;
|
||||
if (it.anchor === "pinned" && w.after.some((id) => (placed.get(id)?.end ?? 0) > it.start)) it.conflict = true;
|
||||
}
|
||||
|
||||
const items = [...placed.values()].sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
|
||||
|
||||
const completion = Math.max(0, ...items.map((i) => i.end));
|
||||
const fullRunRate = Math.max(0, ...items.map((i) => i.savingsFrom));
|
||||
@@ -158,13 +230,11 @@ export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = ne
|
||||
const realizedInHorizon = ramp.slice(0, horizon).reduce((s, v) => s + v / 12, 0);
|
||||
const flatInHorizon = items.reduce((s, i) => s + i.annualSavings, 0) * a.horizonYears;
|
||||
|
||||
// duration and workstream were only needed for scheduling.
|
||||
const clean: PlanItem[] = items.map(({ duration: _d, workstream: _w, ...rest }) => rest);
|
||||
|
||||
return {
|
||||
startIso, items: clean, completion, fullRunRate, ramp: ramp.slice(0, span), span,
|
||||
startIso, items, completion, fullRunRate, ramp: ramp.slice(0, span), span,
|
||||
realizedInHorizon, flatInHorizon,
|
||||
bridgeCount: clean.filter((i) => i.bridge).length,
|
||||
bridgeCount: items.filter((i) => i.bridge).length,
|
||||
conflictCount: items.filter((i) => i.conflict).length,
|
||||
unmappedCount: r.included.length - mapped.length,
|
||||
};
|
||||
}
|
||||
|
||||
105
src/lib/types.ts
105
src/lib/types.ts
@@ -64,8 +64,68 @@ export type Candidate = {
|
||||
notes: string;
|
||||
/** Excluded from rollups and the customer report without being deleted. */
|
||||
included: boolean;
|
||||
|
||||
/**
|
||||
* Total cost of ownership beyond licence. `tcoDefaults` is the knowledge-base
|
||||
* baseline copied onto the line; `tco` holds the seller's overrides (null =
|
||||
* use the default). Admin labour is in FTEs, priced at the assessment's FTE cost.
|
||||
*/
|
||||
tcoDefaults?: TcoDefaults;
|
||||
tco?: { infra: number | null; adminFte: number | null; other: number | null };
|
||||
|
||||
/** Optional per-line pricing for our replacement (internal). Blank = share of the deal-level figure. */
|
||||
investment?: { annual: number | null; services: number | null };
|
||||
|
||||
/** Seller overrides to the generated implementation plan. */
|
||||
plan?: PlanOverride;
|
||||
|
||||
/** Set when a mapped product is already entitled but undeployed: no new subscription is needed. */
|
||||
alreadyEntitled?: string;
|
||||
};
|
||||
|
||||
export type TcoDefaults = {
|
||||
/** Infrastructure, as % of licence spend. */
|
||||
infraPct: number;
|
||||
/** Admin labour in FTEs. */
|
||||
adminFte: number;
|
||||
/** Training, integrations and other run costs, as % of licence spend. */
|
||||
otherPct: number;
|
||||
};
|
||||
|
||||
export type PlanOverride = {
|
||||
/** Pinned start month, yyyy-mm. */
|
||||
start?: string;
|
||||
/** Migration length in months. */
|
||||
duration?: number;
|
||||
/** Candidate ids that must finish before this one starts. */
|
||||
after?: string[];
|
||||
};
|
||||
|
||||
export type Finance = {
|
||||
/** Annual discount rate for NPV, %. */
|
||||
discountRate: number;
|
||||
/** Fully loaded annual cost of one admin FTE, USD. */
|
||||
fteCost: number;
|
||||
/** Our proposal, internal only: annual subscription and one-time services. */
|
||||
investment: { annual: number; services: number };
|
||||
};
|
||||
|
||||
export type Scenario = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "preset" | "custom";
|
||||
/** Candidate ids left out of this scenario. Presets follow the base assessment's scope. */
|
||||
excluded?: string[];
|
||||
/** Multiplier on the cost each line removes, e.g. 0.75 for conservative. */
|
||||
valueFactor: number;
|
||||
/** Parallel migrations per business unit; absent = the assessment's own setting. */
|
||||
parallel?: number;
|
||||
/** Replaces the deal-level investment for this scenario (internal). */
|
||||
investment?: { annual: number; services: number };
|
||||
};
|
||||
|
||||
export type DealStatus = "open" | "won" | "lost" | "paused";
|
||||
|
||||
export type Assessment = {
|
||||
id: string;
|
||||
version: number;
|
||||
@@ -80,6 +140,16 @@ export type Assessment = {
|
||||
candidates: Candidate[];
|
||||
/** Implementation plan: migrations each business-unit workstream runs in parallel (default 2). */
|
||||
planParallel?: number;
|
||||
finance?: Finance;
|
||||
/** Custom scenarios; the three presets are derived, not stored. */
|
||||
scenarios?: Scenario[];
|
||||
/** Scenario the report, Word file and deck present ("expected" = the assessment as it stands). */
|
||||
presentedScenario?: string;
|
||||
/** Adds a side-by-side scenario comparison to the customer deliverables. */
|
||||
showScenarioComparison?: boolean;
|
||||
status?: DealStatus;
|
||||
/** Customer logo as a PNG or JPEG data URL. */
|
||||
customerLogo?: string;
|
||||
/** Imported account dossier. Internal context; never rendered to the customer. */
|
||||
accountIntel?: AccountIntel | null;
|
||||
createdAt: string;
|
||||
@@ -88,9 +158,33 @@ export type Assessment = {
|
||||
|
||||
export type AssessmentSummary = Pick<
|
||||
Assessment,
|
||||
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt"
|
||||
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt" | "status"
|
||||
> & { candidateCount: number; annualSpend: number; annualSavings: number };
|
||||
|
||||
/* ------------------------------------------------------------ branding */
|
||||
|
||||
export type Branding = {
|
||||
companyName: string;
|
||||
/** Hex accent colour used on reports and decks. */
|
||||
accent: string;
|
||||
/** Our logo as a PNG or JPEG data URL. */
|
||||
logo: string;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ snapshots */
|
||||
|
||||
export type SnapshotMeta = {
|
||||
id: string;
|
||||
assessmentId: string;
|
||||
label: string;
|
||||
trigger: "manual" | "word" | "deck" | "print" | "restore";
|
||||
createdAt: string;
|
||||
/** Headline at the time, so the list reads without loading every snapshot. */
|
||||
headline: { netAnnual: number; horizonNet: number; tools: number };
|
||||
};
|
||||
|
||||
export type Snapshot = SnapshotMeta & { assessment: Assessment };
|
||||
|
||||
/* ------------------------------------------------- competitive knowledge base */
|
||||
|
||||
/** One recorded use of a knowledge-base entry in a real assessment. */
|
||||
@@ -138,6 +232,13 @@ export type KbEntry = {
|
||||
competitive: boolean;
|
||||
/** Pinned entries are curated: auto-learning never overwrites their defaults. */
|
||||
pinned: boolean;
|
||||
/** TCO baseline copied onto new candidates; absent = the global defaults. */
|
||||
tco?: TcoDefaults;
|
||||
/** Curator who last reviewed the entry, and when (ISO date). */
|
||||
owner?: string;
|
||||
reviewedAt?: string;
|
||||
/** Curator's override of the computed confidence. */
|
||||
confidenceOverride?: "high" | "medium" | "low";
|
||||
origin: "catalog" | "learned" | "manual";
|
||||
observations: KbObservation[];
|
||||
createdAt: string;
|
||||
@@ -148,6 +249,8 @@ export type KnowledgeBase = {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
entries: KbEntry[];
|
||||
/** Duplicate pairs a curator said are genuinely different ("idA|idB", sorted). */
|
||||
distinctPairs?: string[];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------ account intelligence */
|
||||
|
||||
Reference in New Issue
Block a user