131 lines
7.5 KiB
JavaScript
131 lines
7.5 KiB
JavaScript
/**
|
|
* 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);
|