From 45c90438ba46bf26ddc93032f75251cd9783a752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 10:12:05 +0200 Subject: [PATCH] refactor(api): extract computation graph project snapshot --- .../computation-graph-router.test.ts | 20 + .../src/router/computation-graph-project.ts | 401 ++++++++++++++++ .../src/router/computation-graph-shared.ts | 46 ++ packages/api/src/router/computation-graph.ts | 452 +----------------- 4 files changed, 470 insertions(+), 449 deletions(-) create mode 100644 packages/api/src/router/computation-graph-project.ts create mode 100644 packages/api/src/router/computation-graph-shared.ts diff --git a/packages/api/src/__tests__/computation-graph-router.test.ts b/packages/api/src/__tests__/computation-graph-router.test.ts index 92032ec..d55026b 100644 --- a/packages/api/src/__tests__/computation-graph-router.test.ts +++ b/packages/api/src/__tests__/computation-graph-router.test.ts @@ -315,4 +315,24 @@ describe("computation graph router", () => { expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true); expect(result.links?.length).toBe(result.selectedLinkCount); }); + + it("returns the project snapshot graph through the base router contract", async () => { + const db = createProjectDb(vi.fn().mockResolvedValue(buildProject())); + + const caller = createControllerCaller(db); + const result = await caller.getProjectData({ + projectId: "project_1", + }); + + expect(result.meta).toEqual({ + projectName: "Gelddruckmaschine", + projectCode: "GDM", + }); + expect(result.nodes.map((node) => node.id)).toEqual(expect.arrayContaining([ + "input.budgetCents", + "input.winProbability", + "budget.allocatedCents", + ])); + expect(result.links.length).toBeGreaterThan(0); + }); }); diff --git a/packages/api/src/router/computation-graph-project.ts b/packages/api/src/router/computation-graph-project.ts new file mode 100644 index 0000000..76282fc --- /dev/null +++ b/packages/api/src/router/computation-graph-project.ts @@ -0,0 +1,401 @@ +import { + computeBudgetStatus, + computeEvenSpread, + distributeHoursToWeeks, + summarizeEstimateDemandLines, +} from "@capakraken/engine"; +import type { TRPCContext } from "../trpc.js"; +import { fmtEur } from "../lib/format-utils.js"; +import { type GraphLink, type GraphNode, fmtNum, l, n } from "./computation-graph-shared.js"; + +type ProjectGraphInput = { + projectId: string; +}; + +export async function readProjectGraphSnapshot( + ctx: { db: TRPCContext["db"] }, + input: ProjectGraphInput, +) { + const project = await ctx.db.project.findUniqueOrThrow({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }); + + const estimate = await ctx.db.estimate.findFirst({ + where: { projectId: input.projectId }, + select: { + id: true, + versions: { + orderBy: { versionNumber: "desc" }, + take: 1, + select: { + id: true, + commercialTerms: true, + demandLines: { + select: { + id: true, + hours: true, + costRateCents: true, + billRateCents: true, + costTotalCents: true, + priceTotalCents: true, + chapter: true, + monthlySpread: true, + scopeItemId: true, + resourceId: true, + }, + }, + scopeItems: { + select: { + id: true, + name: true, + scopeType: true, + frameCount: true, + itemCount: true, + unitMode: true, + }, + }, + resourceSnapshots: { + select: { + id: true, + resourceId: true, + displayName: true, + chapter: true, + lcrCents: true, + ucrCents: true, + location: true, + level: true, + }, + }, + }, + }, + }, + orderBy: { updatedAt: "desc" }, + }); + const latestVersion = estimate?.versions[0]; + + let effortRuleCount = 0; + let experienceRuleCount = 0; + try { + effortRuleCount = await ctx.db.effortRule.count(); + experienceRuleCount = await ctx.db.experienceMultiplierRule.count(); + } catch { + // tables may not exist yet + } + + 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), + ); + } + } + + const projectAllocations = await ctx.db.assignment.findMany({ + where: { projectId: input.projectId }, + select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, + }); + + 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, + }, + }; +} diff --git a/packages/api/src/router/computation-graph-shared.ts b/packages/api/src/router/computation-graph-shared.ts new file mode 100644 index 0000000..fb676d3 --- /dev/null +++ b/packages/api/src/router/computation-graph-shared.ts @@ -0,0 +1,46 @@ +type Domain = + | "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET" + | "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD"; + +export interface GraphNode { + id: string; + label: string; + value: number | string; + unit: string; + domain: Domain; + description: string; + formula?: string; + level: number; +} + +export interface GraphLink { + source: string; + target: string; + formula: string; + weight: number; +} + +export function n( + id: string, + label: string, + value: number | string, + unit: string, + domain: Domain, + description: string, + level: number, + formula?: string, +): GraphNode { + return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) }; +} + +export function l(source: string, target: string, formula: string, weight = 1): GraphLink { + return { source, target, formula, weight }; +} + +export function fmtPct(ratio: number): string { + return `${(ratio * 100).toFixed(1)}%`; +} + +export function fmtNum(value: number, decimals = 1): string { + return value.toFixed(decimals); +} diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index 1118d4c..450c668 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -5,9 +5,6 @@ import { computeBudgetStatus, getMonthRange, DEFAULT_CALCULATION_RULES, - summarizeEstimateDemandLines, - computeEvenSpread, - distributeHoursToWeeks, type AssignmentSlice, } from "@capakraken/engine"; import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; @@ -26,51 +23,9 @@ import { loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js"; - -// ─── Graph Types (mirrored from client for API response) ──────────────────── - -type Domain = - | "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET" - | "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD"; - -export interface GraphNode { - id: string; - label: string; - value: number | string; - unit: string; - domain: Domain; - description: string; - formula?: string; - level: number; -} - -export interface GraphLink { - source: string; - target: string; - formula: string; - weight: number; -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function n( - id: string, label: string, value: number | string, unit: string, - domain: Domain, description: string, level: number, formula?: string, -): GraphNode { - return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) }; -} - -function l(source: string, target: string, formula: string, weight = 1): GraphLink { - return { source, target, formula, weight }; -} - -function fmtPct(ratio: number): string { - return `${(ratio * 100).toFixed(1)}%`; -} - -function fmtNum(v: number, decimals = 1): string { - return v.toFixed(decimals); -} +import { readProjectGraphSnapshot } from "./computation-graph-project.js"; +import { type GraphLink, type GraphNode, fmtPct, fmtNum, l, n } from "./computation-graph-shared.js"; +export type { GraphLink, GraphNode } from "./computation-graph-shared.js"; function getAvailabilityHoursForDate( availability: WeekdayAvailability, @@ -660,404 +615,3 @@ async function readResourceGraphSnapshot( }, }; } - -async function readProjectGraphSnapshot( - ctx: { db: TRPCContext["db"] }, - input: z.infer, -) { - const project = await ctx.db.project.findUniqueOrThrow({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }); - - const estimate = await ctx.db.estimate.findFirst({ - where: { projectId: input.projectId }, - select: { - id: true, - versions: { - orderBy: { versionNumber: "desc" }, - take: 1, - select: { - id: true, - commercialTerms: true, - demandLines: { - select: { - id: true, - hours: true, - costRateCents: true, - billRateCents: true, - costTotalCents: true, - priceTotalCents: true, - chapter: true, - monthlySpread: true, - scopeItemId: true, - resourceId: true, - }, - }, - scopeItems: { - select: { - id: true, - name: true, - scopeType: true, - frameCount: true, - itemCount: true, - unitMode: true, - }, - }, - resourceSnapshots: { - select: { - id: true, - resourceId: true, - displayName: true, - chapter: true, - lcrCents: true, - ucrCents: true, - location: true, - level: true, - }, - }, - }, - }, - }, - orderBy: { updatedAt: "desc" }, - }); - const latestVersion = estimate?.versions[0]; - - let effortRuleCount = 0; - let experienceRuleCount = 0; - try { - effortRuleCount = await ctx.db.effortRule.count(); - experienceRuleCount = await ctx.db.experienceMultiplierRule.count(); - } catch { - // tables may not exist yet - } - - 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 dl of lines) { - const ch = dl.chapter ?? "(none)"; - chapterMap.set(ch, (chapterMap.get(ch) ?? 0) + dl.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((s, si) => s + (si.frameCount ?? 0), 0); - const totalItemCount = scopeItems.reduce((s, si) => s + (si.itemCount ?? 0), 0); - const scopeTypes = new Set(scopeItems.map((si) => si.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 adjCost = totalCostCents + contingencyCents; - const adjPrice = totalPriceCents - discountCents; - const adjMargin = adjPrice - adjCost; - const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 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(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"), - n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"), - n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"), - n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.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((m) => `${m.label}: ${m.percent}%`).join(", ")})`, 2), - n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((s, m) => s + m.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 monthCount = spreadResult.months.length; - - const weeklyResult = distributeHoursToWeeks({ - totalHours, - startDate: project.startDate!.toISOString().slice(0, 10), - endDate: project.endDate!.toISOString().slice(0, 10), - pattern: "even", - }); - const weekCount = weeklyResult.weeks.length; - - const hasManualSpreads = lines.some((dl) => { - const spread = dl.monthlySpread as Record | null; - return spread && Object.keys(spread).length > 0; - }); - - nodes.push( - n("spread.monthCount", "Months", `${monthCount}`, "count", "SPREAD", `${monthCount} months in project date range`, 1), - n("spread.weekCount", "Weeks", `${weekCount}`, "count", "SPREAD", `${weekCount} 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), - ); - } - } - - const projectAllocs = await ctx.db.assignment.findMany({ - where: { projectId: input.projectId }, - select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, - }); - - if (projectAllocs.length > 0) { - const budgetStatus = computeBudgetStatus( - project.budgetCents, - project.winProbability, - projectAllocs.map((pa) => ({ - status: pa.status as unknown as string, - dailyCostCents: pa.dailyCostCents, - startDate: pa.startDate, - endDate: pa.endDate, - hoursPerDay: pa.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", `${projectAllocs.length}`, "count", "BUDGET", `${projectAllocs.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 estCost = latestVersion.demandLines.reduce((s, dl) => s + dl.costTotalCents, 0); - const gapCents = budgetStatus.allocatedCents - estCost; - 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, - }, - }; -}