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:
2026-09-24 15:20:36 -04:00
parent d5c77543e8
commit 4496312845
19 changed files with 1223 additions and 126 deletions

BIN
Brand Guidelines.pdf Normal file

Binary file not shown.

58
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"docx": "^9.5.1",
"next": "15.5.4",
"pptxgenjs": "^3.12.0",
"react": "19.1.1",
"react-dom": "19.1.1"
},
@@ -1189,6 +1190,27 @@
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -1704,12 +1726,48 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pptxgenjs": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.7.3",
"https": "^1.0.0",
"image-size": "^1.0.0",
"jszip": "^3.7.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",

View File

@@ -9,17 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"docx": "^9.5.1",
"next": "15.5.4",
"pptxgenjs": "^3.12.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"docx": "^9.5.1"
"react-dom": "19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9"
"@types/react-dom": "^19.1.9",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2"
}
}

View File

@@ -3,7 +3,7 @@
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf .test-build
npx --yes tsc src/lib/dossier.ts src/lib/kb.ts src/lib/scoring.ts src/lib/client.ts \
npx --yes tsc src/lib/dossier.ts src/lib/kb.ts src/lib/scoring.ts src/lib/client.ts src/lib/finance.ts src/lib/timeline.ts src/lib/shelfware.ts src/lib/portfolio.ts \
--outDir .test-build --target ES2022 --module esnext \
--moduleResolution bundler --skipLibCheck
# tsc emits extensionless relative imports; Node ESM needs the .js suffix.

130
scripts/test-finance.mjs Normal file
View File

@@ -0,0 +1,130 @@
/**
* Financial engine, planner, scenarios, shelfware and knowledge-base curation.
* Pure logic against the compiled libs — no server needed.
*/
import { readFileSync } from 'node:fs';
import { rollup, lineCost } from '../.test-build/scoring.js';
import { evaluate, allScenarios, cashFlow } from '../.test-build/finance.js';
import { buildPlan } from '../.test-build/timeline.js';
import { shelfware } from '../.test-build/shelfware.js';
import { findDuplicates, mergeEntries, learnFromAssessment, newEntry, confidenceOf } from '../.test-build/kb.js';
let pass = 0, fail = 0;
const t = (n, ok, x = '') => { ok ? (pass++, console.log(' PASS', n)) : (fail++, console.log(' FAIL', n, x)); };
const near = (a, b, tol = 1) => Math.abs(a - b) <= tol;
const catalog = JSON.parse(readFileSync(new URL('../data/catalog.json', import.meta.url)));
const TODAY = new Date('2026-09-24T12:00:00Z'); // plan month 0 = Oct 2026
const line = (id, over = {}) => ({
id, incumbentVendor: `Vendor${id}`, incumbentProduct: 'Tool', domain: 'observability',
productIds: ['aod-dx-oi'], annualSpend: 1_000_000, units: 0, unitLabel: '', renewalDate: '',
capability: 4, switchEase: 4, timeToRoiMonths: 6, savingsPct: 50, migrationCost: 100_000,
strategicValue: 3, risks: '', notes: '', included: true, ...over,
});
const base = (candidates, extra = {}) => ({
id: 't', version: 1, customerName: 'T', industry: '', preparedBy: '', preparedFor: '', opportunityRef: '',
executiveSummary: '', horizonYears: 3, weights: { capability: 35, switchEase: 25, timeToRoi: 25, spend: 15 },
candidates, createdAt: '', updatedAt: '', ...extra,
});
console.log('TCO');
{
const c = lineCost(line('a'), 150_000);
t('default TCO = licence + 15% infra + 0.25 FTE + 5% other', near(c.tco, 1_000_000 + 150_000 + 37_500 + 50_000), c.tco);
t('unknown spend carries no phantom admin', lineCost(line('b', { annualSpend: 0 }), 150_000).tco === 0);
const o = lineCost(line('c', { tco: { infra: 10, adminFte: 2, other: null } }), 100_000);
t('overrides win per category', o.infra === 10 && o.admin === 200_000 && near(o.other, 50_000));
}
console.log('Savings model');
{
const r = rollup(base([line('a')]), catalog);
const m = r.metrics.get('a');
t('no investment: net = savings % of TCO', near(m.annualSavings, m.tco * 0.5) && !r.hasInvestment);
const inv = base([line('a'), line('b', { annualSpend: 3_000_000 }), line('c', { investment: { annual: 50_000, services: null } }), line('d', { alreadyEntitled: 'X' })],
{ finance: { discountRate: 10, fteCost: 150_000, investment: { annual: 1_000_000, services: 200_000 } } });
const ri = rollup(inv, catalog);
const [ma, mb, mc, md] = ['a', 'b', 'c', 'd'].map((k) => ri.metrics.get(k));
t('with investment: gross = full TCO', near(ma.grossRemoved, ma.tco));
t('per-line price wins', mc.subscription === 50_000);
t('entitled line takes no subscription share', md.subscription === 0);
t('pool shared by TCO', near(ma.subscription / mb.subscription, ma.tco / mb.tco, 0.001));
t('subscriptions sum to the deal figure', near(ri.totalSubscription, 1_000_000, 2), ri.totalSubscription);
t('services pool fully attributed', near(ri.totalMigrationCost, 4 * 100_000 + 200_000, 2), ri.totalMigrationCost);
}
console.log('Cash flow');
{
const a = base([line('a'), line('b', { renewalDate: '2027-06-30', switchEase: 3 })],
{ finance: { discountRate: 10, fteCost: 150_000, investment: { annual: 400_000, services: 0 } } });
const e = evaluate(a, catalog, undefined, TODAY);
const sumYears = e.cash.years.reduce((s, y) => s + y.net, 0);
t('years add up to the horizon net', near(sumYears, e.cash.horizonNet, 2), `${sumYears} vs ${e.cash.horizonNet}`);
t('subscription billed from month 0', e.cash.months[0] < 0);
t('NPV below undiscounted net', e.cash.npv < e.cash.horizonNet);
t('payback found', e.cash.paybackMonth !== null && e.cash.paybackMonth > 0, e.cash.paybackMonth);
t('IRR solved', e.cash.irr !== null && e.cash.irr > 0, e.cash.irr);
const b = e.plan.items.find((i) => i.id === 'b');
t('contract line cuts over by contract end (Jun 2027 = month 8)', b.end <= 9 && b.savingsFrom === 9, JSON.stringify(b));
}
console.log('Planner overrides');
{
const a = base([
line('p', { plan: { start: '2027-04', duration: 4 } }),
line('q', { plan: { after: ['p'] } }),
line('x', { plan: { after: ['y'] } }), line('y', { plan: { after: ['x'] } }),
]);
const r = rollup(a, catalog);
const plan = buildPlan(a, r, catalog, TODAY);
const get = (id) => plan.items.find((i) => i.id === id);
t('pinned start and duration honoured', get('p').start === 6 && get('p').end === 10 && get('p').anchor === 'pinned');
t('dependency waits for predecessor', get('q').start >= get('p').end, `${get('q').start}`);
t('dependency cycle still schedules', !!get('x') && !!get('y'));
const pinnedEarly = base([line('p'), line('q', { plan: { start: '2026-10', after: ['p'] } })]);
const pe = buildPlan(pinnedEarly, rollup(pinnedEarly, catalog), catalog, TODAY);
t('pinned before its predecessor is flagged', pe.items.find((i) => i.id === 'q').conflict && pe.conflictCount === 1);
}
console.log('Scenarios');
{
const a = base([line('a'), line('b'), line('c')], { scenarios: [{ id: 's1', name: 'Only A', kind: 'custom', valueFactor: 1, excluded: ['b', 'c'] }] });
const net = Object.fromEntries(allScenarios(a).map((s) => [s.id, evaluate(a, catalog, s, TODAY).cash.runRateNet]));
t('conservative < expected < aggressive', net.conservative < net.expected && net.expected < net.aggressive, JSON.stringify(net));
t('custom scope excludes lines', near(net.s1 * 3, net.expected, 5), `${net.s1} vs ${net.expected}`);
t('scenario does not mutate the assessment', a.candidates.every((c) => c.included));
}
console.log('Shelfware');
{
const a = base([line('lm', { productIds: ['aod-dx-uim'] })], { accountIntel: { consumption: [
{ product: 'UIM', entitled: 100, deployed: 40, adoption: '' },
{ product: 'VCF OPERATIONS', entitled: 10, deployed: 0, adoption: '' },
] } });
const s = shelfware(a, catalog);
t('acronym prefers the product in the plan', s[0].match?.id === 'aod-dx-uim' && s[0].linked[0] === 'lm', s[0].match?.name);
t('exact name is decisive', s[1].match?.name === 'VCF Operations', s[1].match?.name);
}
console.log('Knowledge base curation');
{
const kb = { version: 1, updatedAt: '', entries: [newEntry('F5 BIG-IP', 'manual'), newEntry('F5 BIG-IP LTM', 'manual'), newEntry('Microsoft Entra ID', 'manual')] };
kb.entries.forEach((e) => (e.domain = 'adc'));
const dups = findDuplicates(kb);
t('finds F5 BIG-IP / F5 BIG-IP LTM', dups.some((d) => [d.a.label, d.b.label].sort().join('|') === 'F5 BIG-IP|F5 BIG-IP LTM'));
const a = base([line('f5', { incumbentVendor: 'F5', incumbentProduct: 'BIG-IP', domain: 'adc', productIds: ['ans-avi'] })]);
const learned = learnFromAssessment(kb, a);
const merged = mergeEntries(learned, 'f5-big-ip-ltm', 'f5-big-ip');
const ltm = merged.entries.find((e) => e.id === 'f5-big-ip-ltm');
t('merge moves deal history and adds alias', ltm.observations.length === 1 && ltm.aliases.includes('F5 BIG-IP'));
t('merged entry is removed', !merged.entries.some((e) => e.id === 'f5-big-ip'));
const relearn = learnFromAssessment(merged, { ...a, updatedAt: 'x' }) ?? merged;
t('learning after merge lands on the survivor', !relearn.entries.some((e) => e.id === 'f5-big-ip')
&& relearn.entries.find((e) => e.id === 'f5-big-ip-ltm').observations.length === 1);
t('one deal = low confidence', confidenceOf(ltm).level === 'low');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

View File

@@ -18,6 +18,10 @@ echo "=== dossier parser ==="
./scripts/build-test-lib.sh
node scripts/test-dossier.mjs || fail=1
echo
echo "=== financial engine, planner, scenarios, curation ==="
node scripts/test-finance.mjs || fail=1
echo
echo "=== knowledge base: learning ==="
reseed

View 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);
}

View 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));
}

View 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));
}

View 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
View 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
View 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;
}

View File

@@ -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
View 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),
};
}

View File

@@ -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
View 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;
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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 */