Files
Nexus/packages/api/src/router/computation-graph.ts
T
Hartmut fd400122a4 feat: computation graph — add all missing engine variables and calculations
Added 30+ new nodes covering every engine calculation module:
- SAH: publicHolidayDays, scheduleRules separation
- Allocation: workingDays, utilizationPct intermediates
- Chargeability: bd/mdi/mo/pdr/absence ratio breakdowns
- Effort rules (new domain): scopeItems, totalFrames, expandedHours
- Experience multipliers (new domain): costMultiplier, billMultiplier, shoringRatio
- Commercial terms: pricingModel, paymentTermDays, warrantyMonths, milestones
- Spread (new domain): monthCount, weekCount, monthlySpread, weeklyPhasing
- Budget: allocCount, estVsActualGap
- Project: projectStart/End, resourceSnapshots, chapters

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-22 20:33:07 +01:00

923 lines
47 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 {
calculateSAH,
calculateAllocation,
deriveResourceForecast,
computeBudgetStatus,
getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice,
} from "@planarchy/engine";
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.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);
}
// ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({
/**
* Resource View: SAH, Allocation, Rules, Chargeability, Budget
* for a single resource in a single month.
*/
getResourceData: controllerProcedure
.input(z.object({
resourceId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/),
}))
.query(async ({ ctx, input }) => {
const [year, month] = input.month.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
// ── 1. Load resource ──
const resource = await ctx.db.resource.findUniqueOrThrow({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
eid: true,
fte: true,
lcrCents: true,
chargeabilityTarget: true,
availability: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);
// Resource weekly availability (per-day hours)
const avail = resource.availability as WeekdayAvailability | null;
const weeklyAvailability: WeekdayAvailability = avail ?? {
monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours,
thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0,
};
// ── 2. Load assignments in month ──
const assignments = await ctx.db.assignment.findMany({
where: {
resourceId: input.resourceId,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] },
},
select: {
id: true,
hoursPerDay: true,
startDate: true,
endDate: true,
dailyCostCents: true,
status: true,
project: {
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
winProbability: true,
utilizationCategory: { select: { code: true } },
},
},
},
});
// ── 3. Load absences ──
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
// Build absence dates for SAH (ISO strings), separating public holidays
const publicHolidayStrings: string[] = [];
const absenceDateStrings: string[] = [];
const absenceDays: AbsenceDay[] = [];
let halfDayCount = 0;
let vacationDayCount = 0;
let sickDayCount = 0;
let publicHolidayCount = 0;
for (const v of vacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cursor <= endNorm) {
const isoDate = cursor.toISOString().slice(0, 10);
if (triggerType === "PUBLIC_HOLIDAY") {
publicHolidayStrings.push(isoDate);
publicHolidayCount++;
} else {
absenceDateStrings.push(isoDate);
if (triggerType === "VACATION") vacationDayCount++;
if (triggerType === "SICK") sickDayCount++;
}
absenceDays.push({
date: new Date(cursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// ── 5. Calculate SAH ──
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
// ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = [];
let totalAllocHours = 0;
let totalAllocCostCents = 0;
let totalChargeableHours = 0;
let totalProjectCostCents = 0;
let hasRulesEffect = false;
for (const a of assignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
if (calcResult.totalChargeableHours !== undefined) {
totalChargeableHours += calcResult.totalChargeableHours;
totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
hasRulesEffect = true;
} else {
totalChargeableHours += calcResult.totalHours;
totalProjectCostCents += calcResult.totalCostCents;
}
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
: {}),
});
}
// ── 7. Calculate chargeability forecast ──
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
// ── 8. Build budget status for first project with budget ──
const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project;
let budgetNodes: GraphNode[] = [];
let budgetLinks: GraphLink[] = [];
if (budgetProject && budgetProject.budgetCents != null) {
// Load all allocations for this project to compute budget
const projectAllocs = await ctx.db.assignment.findMany({
where: { projectId: budgetProject.id },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
});
const budgetStatus = computeBudgetStatus(
budgetProject.budgetCents,
budgetProject.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],
monthStart,
monthEnd,
);
budgetNodes = [
n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0),
n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0),
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"),
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"),
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"),
n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"),
n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"),
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"),
];
budgetLinks = [
l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 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),
];
}
// ── 9. Build graph nodes + links ──
const dailyCostCents = assignments.length > 0
? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents)
: 0;
const avgHoursPerDay = assignments.length > 0
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
}, 0);
// Format weekly availability for display
const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"];
const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday];
const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0);
const allSame = weekdayValues.every((v) => v === weekdayValues[0]);
const availabilityLabel = allSame
? `${weekdayValues[0]}h/day`
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
// Derived utilization ratio
const utilizationPct = sahResult.standardAvailableHours > 0
? (totalAllocHours / sahResult.standardAvailableHours) * 100
: 0;
// Has schedule rules (Spain variable hours)?
const hasScheduleRules = !!scheduleRules;
const nodes: GraphNode[] = [
// INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
...(hasScheduleRules ? [
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
] : []),
n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0),
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
// SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
// ALLOCATION
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"),
n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"),
n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"),
n("alloc.utilizationPct", "Utilization", `${utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"),
...(hasRulesEffect ? [
n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"),
n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"),
] : []),
// RULES (only if absences exist)
...(absenceDays.length > 0 ? [
n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1),
n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"),
n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"),
...(hasRulesEffect ? [
n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100"),
] : []),
] : []),
// CHARGEABILITY — full breakdown from deriveResourceForecast
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
...(forecast.bd > 0 ? [
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
] : []),
...(forecast.mdi > 0 ? [
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
] : []),
...(forecast.mo > 0 ? [
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
] : []),
...(forecast.pdr > 0 ? [
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
] : []),
...(forecast.absence > 0 ? [
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
] : []),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability target"),
// Budget nodes (conditionally added above)
...budgetNodes,
];
const links: GraphLink[] = [
// INPUT → SAH
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
...(hasScheduleRules ? [
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
] : []),
l("sah.calendarDays", "sah.grossWorkingDays", " weekends", 2),
l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2),
l("sah.publicHolidayDays", "sah.netWorkingDays", "", 1),
l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1),
l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2),
l("input.hoursPerDay", "alloc.workingDays", "per assignment", 1),
l("input.assignmentCount", "alloc.workingDays", "× overlap", 1),
l("alloc.workingDays", "alloc.totalHours", "× h/day", 2),
l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1),
l("alloc.dailyCostCents", "alloc.totalCostCents", "× workdays", 2),
l("alloc.workingDays", "alloc.totalCostCents", "×", 1),
l("alloc.totalHours", "alloc.utilizationPct", "÷ SAH × 100", 2),
l("sah.sah", "alloc.utilizationPct", "÷", 1),
// RULES → ALLOCATION (if absences)
...(absenceDays.length > 0 ? [
l("input.calcRules", "rules.activeRules", "filter active", 1),
l("input.absences", "rules.activeRules", "match trigger", 1),
l("rules.activeRules", "rules.costEffect", "→ effect", 1),
l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
] : []),
...(hasRulesEffect ? [
l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
...(absenceDays.length > 0 ? [
l("rules.costEffect", "rules.costReduction", "reduce %", 1),
] : []),
] : []),
// ALLOCATION + SAH → CHARGEABILITY
l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2),
l("chg.chgHours", "chg.chg", "÷ SAH", 2),
l("sah.sah", "chg.chg", "÷", 2),
...(forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []),
...(forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []),
...(forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []),
...(forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []),
...(forecast.absence > 0 ? [l("sah.sah", "chg.absence", "÷", 1)] : []),
l("sah.sah", "chg.unassigned", " assigned ÷ SAH", 1),
l("chg.chgHours", "chg.unassigned", "SAH Σ", 1),
l("input.targetPct", "chg.target", "=", 1),
l("chg.chg", "chg.gap", "", 2),
l("chg.target", "chg.gap", "", 1),
// Budget links (conditionally added above)
...budgetLinks,
];
return {
nodes,
links,
meta: {
resourceName: resource.displayName,
resourceEid: resource.eid,
month: input.month,
assignmentCount: assignments.length,
},
};
}),
/**
* Project View: Estimate, Commercial, Experience, Effort, Spread, Budget
*/
getProjectData: controllerProcedure
.input(z.object({
projectId: z.string(),
}))
.query(async ({ ctx, input }) => {
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,
},
});
// Load latest estimate version with demand lines + scope items
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];
// Load effort rule sets and experience multiplier sets for this project
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[] = [];
// Budget + project inputs
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;
// Average rates
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;
// Chapters
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;
// Resource snapshots
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),
] : []),
);
// ── EFFORT domain: scope items → demand line expansion ──
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),
);
}
// ── EXPERIENCE domain: multiplier rules ──
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),
);
}
// ── COMMERCIAL terms (enhanced) ──
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),
);
}
// Commercial metadata nodes (pricingModel, payment terms, milestones)
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) {
const effectivePrice = hasCommercialAdjustments
? totalPriceCents - Math.round(totalPriceCents * (terms!.discountPercent ?? 0) / 100)
: totalPriceCents;
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),
);
}
}
// ── SPREAD domain: monthly + weekly distribution ──
if (hasDateRange) {
// Compute monthly spread from demand lines
const spreadResult = computeEvenSpread({
totalHours,
startDate: project.startDate!,
endDate: project.endDate!,
});
const monthCount = spreadResult.months.length;
// Compute weekly phasing
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;
// Check if demand lines have manual monthly spreads
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),
);
}
}
// ── Budget status ──
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(),
);
// Total allocated hours for comparison
const totalAllocatedHours = projectAllocs.reduce((sum, pa) => sum + pa.hoursPerDay, 0);
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),
);
// Estimate vs actual gap (if estimate exists)
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,
},
};
}),
});