Files
CapaKraken/packages/api/src/router/computation-graph-project.ts
T

326 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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),
] : []),
);
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 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<string, number> | 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<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 (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,
},
};
}