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