108 lines
5.2 KiB
TypeScript
108 lines
5.2 KiB
TypeScript
import { computeBudgetStatus } from "@capakraken/engine";
|
||
import { fmtEur } from "../lib/format-utils.js";
|
||
import { buildProjectEstimateGraph } from "./computation-graph-project-estimate.js";
|
||
import { loadProjectGraphSnapshot, type ProjectGraphInput } from "./computation-graph-project-snapshot.js";
|
||
import { type GraphLink, type GraphNode, l, n } from "./computation-graph-shared.js";
|
||
|
||
export async function readProjectGraphSnapshot(
|
||
ctx: Parameters<typeof loadProjectGraphSnapshot>[0],
|
||
input: ProjectGraphInput,
|
||
) {
|
||
const {
|
||
project,
|
||
latestVersion,
|
||
effortRuleCount,
|
||
experienceRuleCount,
|
||
projectAllocations,
|
||
} = await loadProjectGraphSnapshot(ctx, input);
|
||
|
||
const nodes: GraphNode[] = [];
|
||
const links: GraphLink[] = [];
|
||
|
||
const hasBudget = project.budgetCents > 0;
|
||
const hasDateRange = !!(project.startDate && project.endDate);
|
||
nodes.push(
|
||
n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
|
||
n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
|
||
...(hasDateRange ? [
|
||
n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0),
|
||
n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0),
|
||
] : []),
|
||
);
|
||
|
||
const estimateGraph = buildProjectEstimateGraph({
|
||
project,
|
||
latestVersion,
|
||
effortRuleCount,
|
||
experienceRuleCount,
|
||
});
|
||
nodes.push(...estimateGraph.nodes);
|
||
links.push(...estimateGraph.links);
|
||
|
||
if (projectAllocations.length > 0) {
|
||
const budgetStatus = computeBudgetStatus(
|
||
project.budgetCents,
|
||
project.winProbability,
|
||
projectAllocations.map((allocation) => ({
|
||
status: allocation.status as unknown as string,
|
||
dailyCostCents: allocation.dailyCostCents,
|
||
startDate: allocation.startDate,
|
||
endDate: allocation.endDate,
|
||
hoursPerDay: allocation.hoursPerDay,
|
||
})) as Parameters<typeof computeBudgetStatus>[2],
|
||
project.startDate ?? new Date(),
|
||
project.endDate ?? new Date(),
|
||
);
|
||
|
||
nodes.push(
|
||
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
|
||
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
|
||
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
|
||
n("budget.remainingCents", "Remaining", hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A", hasBudget ? "EUR" : "—", "BUDGET", hasBudget ? "Remaining budget" : "Cannot compute — no budget set", 3, hasBudget ? "budget - allocated" : "needs budget"),
|
||
n("budget.utilizationPct", "Utilization", hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A", hasBudget ? "%" : "—", "BUDGET", hasBudget ? "Budget utilization" : "Cannot compute — no budget set", 3, hasBudget ? "allocated / budget × 100" : "needs budget"),
|
||
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
|
||
n("budget.allocCount", "Allocations", `${projectAllocations.length}`, "count", "BUDGET", `${projectAllocations.length} resource allocations on project`, 1),
|
||
);
|
||
|
||
links.push(
|
||
l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1),
|
||
l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1),
|
||
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
|
||
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
|
||
l("input.budgetCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
|
||
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
|
||
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
|
||
l("input.winProbability", "budget.weightedCents", "×", 1),
|
||
);
|
||
|
||
if (estimateGraph.estimatedCostCents != null) {
|
||
const estimatedCost = estimateGraph.estimatedCostCents;
|
||
const gapCents = budgetStatus.allocatedCents - estimatedCost;
|
||
nodes.push(
|
||
n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
|
||
gapCents > 0
|
||
? `Actual allocations exceed estimate by ${fmtEur(gapCents)}`
|
||
: gapCents < 0
|
||
? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}`
|
||
: "Actual allocations match estimate",
|
||
3, "allocated - estCost"),
|
||
);
|
||
links.push(
|
||
l("budget.allocatedCents", "budget.estVsActualGap", "−", 1),
|
||
l("est.totalCostCents", "budget.estVsActualGap", "−", 1),
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
nodes,
|
||
links,
|
||
meta: {
|
||
projectName: project.name,
|
||
projectCode: project.shortCode,
|
||
},
|
||
};
|
||
}
|