diff --git a/Brand Guidelines.pdf b/Brand Guidelines.pdf new file mode 100644 index 0000000..582601a Binary files /dev/null and b/Brand Guidelines.pdf differ diff --git a/package-lock.json b/package-lock.json index e5806c2..afbe289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9d9ced0..13d777a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/build-test-lib.sh b/scripts/build-test-lib.sh index 9fd3f39..a2d7ac4 100755 --- a/scripts/build-test-lib.sh +++ b/scripts/build-test-lib.sh @@ -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. diff --git a/scripts/test-finance.mjs b/scripts/test-finance.mjs new file mode 100644 index 0000000..61504f7 --- /dev/null +++ b/scripts/test-finance.mjs @@ -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); diff --git a/scripts/test.sh b/scripts/test.sh index 202319c..9ea4c62 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 diff --git a/src/app/api/assessments/[id]/snapshots/[sid]/route.ts b/src/app/api/assessments/[id]/snapshots/[sid]/route.ts new file mode 100644 index 0000000..172494f --- /dev/null +++ b/src/app/api/assessments/[id]/snapshots/[sid]/route.ts @@ -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); +} diff --git a/src/app/api/assessments/[id]/snapshots/route.ts b/src/app/api/assessments/[id]/snapshots/route.ts new file mode 100644 index 0000000..647b298 --- /dev/null +++ b/src/app/api/assessments/[id]/snapshots/route.ts @@ -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)); +} diff --git a/src/app/api/portfolio/route.ts b/src/app/api/portfolio/route.ts new file mode 100644 index 0000000..47f50d0 --- /dev/null +++ b/src/app/api/portfolio/route.ts @@ -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)); +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..b247c13 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -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 | 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) || "", + })); +} diff --git a/src/lib/finance.ts b/src/lib/finance.ts new file mode 100644 index 0000000..bc4a45c --- /dev/null +++ b/src/lib/finance.ts @@ -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(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) }; +} diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..e002389 --- /dev/null +++ b/src/lib/images.ts @@ -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; +} diff --git a/src/lib/kb.ts b/src/lib/kb.ts index e9b6a98..f057289 100644 --- a/src/lib/kb.ts +++ b/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(); + 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(), + }; +} diff --git a/src/lib/portfolio.ts b/src/lib/portfolio.ts new file mode 100644 index 0000000..d838303 --- /dev/null +++ b/src/lib/portfolio.ts @@ -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 = { 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 }>(); + const comp = new Map; lines: number; tco: number }>(); + const status = new Map(); + 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() }; + 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(), 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), + }; +} diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts index aa9796b..b2bd67b 100644 --- a/src/lib/scoring.ts +++ b/src/lib/scoring.ts @@ -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): 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; 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(); 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 }>(); for (const c of included) { const m = metrics.get(c.id)!; const bus = [...new Set(c.productIds.map((id) => prodById.get(id)?.bu).filter(Boolean))] as string[]; - if (!bus.length) continue; for (const bu of bus) { const e = buAgg.get(bu) ?? { savings: 0, spend: 0, migrationCost: 0, count: 0, products: new Set() }; e.savings += m.annualSavings / bus.length; - e.spend += c.annualSpend / bus.length; - e.migrationCost += c.migrationCost / bus.length; + e.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(); + const domAgg = new Map }>(); for (const c of included) { const m = metrics.get(c.id)!; - const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 }; + const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0, vendors: new Set() }; 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, diff --git a/src/lib/shelfware.ts b/src/lib/shelfware.ts new file mode 100644 index 0000000..e4df7af --- /dev/null +++ b/src/lib/shelfware.ts @@ -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 { + const out = new Map(); + 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; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index f5ee98c..9a748df 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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(file: string): Promise { return JSON.parse(await fs.readFile(file, "utf8")) as T; @@ -73,6 +76,7 @@ export async function listAssessments(): Promise { 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 { + try { + return { ...DEFAULT_BRANDING, ...(await readJson>(SETTINGS_PATH)) }; + } catch { + return { ...DEFAULT_BRANDING }; + } +} + +export async function saveBranding(b: Branding): Promise { + 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 { + 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(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 { + if (!/^[a-z0-9-]{1,40}$/.test(id)) return null; + try { + return await readJson(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 { + 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; +} diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts index 7e71da0..6cb0ed3 100644 --- a/src/lib/timeline.ts +++ b/src/lib/timeline.ts @@ -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 = { 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) { + 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(); 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; }; - // 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(); + 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, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index a1d57cc..a426887 100644 --- a/src/lib/types.ts +++ b/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 */