refactor(api): extract computation graph project snapshot
This commit is contained in:
@@ -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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user