refactor(api): extract computation graph project snapshot

This commit is contained in:
2026-03-31 10:12:05 +02:00
parent f24d8deacf
commit 45c90438ba
4 changed files with 470 additions and 449 deletions
+3 -449
View File
@@ -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<typeof projectGraphInputSchema>,
) {
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<string, number>();
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<string, number> | 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<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", `${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,
},
};
}