import { computeBudgetStatus, computeEvenSpread, distributeHoursToWeeks, summarizeEstimateDemandLines, } from "@capakraken/engine"; import { fmtEur } from "../lib/format-utils.js"; import { loadProjectGraphSnapshot, type ProjectGraphInput } from "./computation-graph-project-snapshot.js"; import { type GraphLink, type GraphNode, fmtNum, 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), ] : []), ); if (latestVersion && latestVersion.demandLines.length > 0) { const lines = latestVersion.demandLines; const summary = summarizeEstimateDemandLines(lines); const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary; const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0; const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0; const chapterMap = new Map(); for (const demandLine of lines) { const chapter = demandLine.chapter ?? "(none)"; chapterMap.set(chapter, (chapterMap.get(chapter) ?? 0) + demandLine.hours); } const chapterCount = chapterMap.size; const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0; nodes.push( n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0), n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0), n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0), ...(snapshotCount > 0 ? [ n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0), ] : []), n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"), n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"), n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"), n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"), n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"), ...(chapterCount > 1 ? [ n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1), ] : []), ); links.push( l("input.estLines", "est.totalHours", "Σ hours", 1), l("input.avgCostRate", "est.totalCostCents", "× hours", 2), l("est.totalHours", "est.totalCostCents", "× costRate", 2), l("input.avgBillRate", "est.totalPriceCents", "× hours", 2), l("est.totalHours", "est.totalPriceCents", "× billRate", 2), l("est.totalPriceCents", "est.marginCents", "−", 2), l("est.totalCostCents", "est.marginCents", "−", 2), l("est.marginCents", "est.marginPercent", "÷ price × 100", 2), l("est.totalPriceCents", "est.marginPercent", "÷", 1), ...(snapshotCount > 0 ? [ l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1), l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1), ] : []), ...(chapterCount > 1 ? [ l("input.estLines", "est.chapters", "group by", 1), l("est.chapters", "est.totalHours", "Σ per chapter", 1), ] : []), ); const scopeItems = latestVersion.scopeItems ?? []; if (scopeItems.length > 0) { const totalFrameCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.frameCount ?? 0), 0); const totalItemCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.itemCount ?? 0), 0); const scopeTypes = new Set(scopeItems.map((scopeItem) => scopeItem.scopeType)); nodes.push( n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0), ...(totalFrameCount > 0 ? [ n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1), ] : []), ...(totalItemCount > 0 ? [ n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1), ] : []), n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0), n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"), ); links.push( l("effort.scopeItems", "effort.expandedHours", "expand", 2), l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2), ...(totalFrameCount > 0 ? [ l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1), l("effort.totalFrames", "effort.expandedHours", "per_frame", 1), ] : []), ...(totalItemCount > 0 ? [ l("effort.scopeItems", "effort.totalItems", "Σ items", 1), l("effort.totalItems", "effort.expandedHours", "per_item", 1), ] : []), l("effort.expandedHours", "est.totalHours", "→ demand lines", 2), ); } if (experienceRuleCount > 0) { nodes.push( n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0), n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"), n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"), n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"), n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"), ); links.push( l("exp.ruleCount", "exp.costMultiplier", "match rule", 1), l("exp.ruleCount", "exp.billMultiplier", "match rule", 1), l("exp.ruleCount", "exp.shoringRatio", "match rule", 1), l("exp.costMultiplier", "exp.adjustedRates", "×", 2), l("exp.billMultiplier", "exp.adjustedRates", "×", 2), l("exp.shoringRatio", "exp.adjustedRates", "adjust hours", 1), l("exp.adjustedRates", "est.totalCostCents", "→ costRate", 1), l("exp.adjustedRates", "est.totalPriceCents", "→ billRate", 1), ); } const terms = latestVersion.commercialTerms as { contingencyPercent?: number; discountPercent?: number; pricingModel?: string; paymentTermDays?: number; warrantyMonths?: number; paymentMilestones?: Array<{ label: string; percent: number; dueDate?: string | null }>; } | null; const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent); const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths); if (hasCommercialAdjustments) { const contingencyPct = terms!.contingencyPercent ?? 0; const discountPct = terms!.discountPercent ?? 0; const contingencyCents = Math.round((totalCostCents * contingencyPct) / 100); const discountCents = Math.round((totalPriceCents * discountPct) / 100); const adjustedCost = totalCostCents + contingencyCents; const adjustedPrice = totalPriceCents - discountCents; const adjustedMargin = adjustedPrice - adjustedCost; const adjustedMarginPct = adjustedPrice > 0 ? (adjustedMargin / adjustedPrice) * 100 : 0; nodes.push( n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0), n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0), n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"), n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"), n("comm.adjustedCost", "Adj. Cost", fmtEur(adjustedCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"), n("comm.adjustedPrice", "Adj. Price", fmtEur(adjustedPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"), n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjustedMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"), n("comm.adjustedMarginPct", "Adj. Margin %", `${adjustedMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"), ); links.push( l("est.totalCostCents", "comm.contingencyCents", "×", 1), l("input.contingencyPct", "comm.contingencyCents", "× %", 1), l("est.totalPriceCents", "comm.discountCents", "×", 1), l("input.discountPct", "comm.discountCents", "× %", 1), l("est.totalCostCents", "comm.adjustedCost", "+", 2), l("comm.contingencyCents", "comm.adjustedCost", "+", 2), l("est.totalPriceCents", "comm.adjustedPrice", "−", 2), l("comm.discountCents", "comm.adjustedPrice", "−", 2), l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2), l("comm.adjustedCost", "comm.adjustedMargin", "−", 2), l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2), l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1), ); } if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) { if (terms!.pricingModel) { nodes.push( n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0), ); } if (terms!.paymentTermDays) { nodes.push( n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0), ); } if (terms!.warrantyMonths) { nodes.push( n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0), ); } const milestones = terms!.paymentMilestones ?? []; if (milestones.length > 0) { nodes.push( n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((milestone) => `${milestone.label}: ${milestone.percent}%`).join(", ")})`, 2), n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((sum, milestone) => sum + milestone.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"), ); links.push( l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1), ); } } if (hasDateRange) { const spreadResult = computeEvenSpread({ totalHours, startDate: project.startDate!, endDate: project.endDate!, }); const weeklyResult = distributeHoursToWeeks({ totalHours, startDate: project.startDate!.toISOString().slice(0, 10), endDate: project.endDate!.toISOString().slice(0, 10), pattern: "even", }); const hasManualSpreads = lines.some((demandLine) => { const spread = demandLine.monthlySpread as Record | null; return spread && Object.keys(spread).length > 0; }); nodes.push( n("spread.monthCount", "Months", `${spreadResult.months.length}`, "count", "SPREAD", `${spreadResult.months.length} months in project date range`, 1), n("spread.weekCount", "Weeks", `${weeklyResult.weeks.length}`, "count", "SPREAD", `${weeklyResult.weeks.length} ISO weeks in project date range`, 1), n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"), n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"), n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"), ); links.push( l("input.projectStart", "spread.monthCount", "→ range", 1), l("input.projectEnd", "spread.monthCount", "→ range", 1), l("input.projectStart", "spread.weekCount", "→ range", 1), l("input.projectEnd", "spread.weekCount", "→ range", 1), l("est.totalHours", "spread.monthlySpread", "distribute", 2), l("spread.monthCount", "spread.monthlySpread", "÷ by workdays", 1), l("est.totalHours", "spread.weeklyPhasing", "distribute", 2), l("spread.weekCount", "spread.weeklyPhasing", "÷ by weeks", 1), l("spread.weeklyPhasing", "spread.totalDistributed", "Σ", 2), ); } } 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 (latestVersion && latestVersion.demandLines.length > 0) { const estimatedCost = latestVersion.demandLines.reduce((sum, demandLine) => sum + demandLine.costTotalCents, 0); 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, }, }; }