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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user