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>
This commit is contained in:
2026-03-22 20:33:07 +01:00
parent 2988e7bf0b
commit fd400122a4
2 changed files with 335 additions and 29 deletions
@@ -175,7 +175,7 @@ export function DispoImportClient() {
Dispo Import Dispo Import
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage V2 data imports from Dispo workbooks Manage Dispo imports
</p> </p>
</div> </div>
<Button onClick={() => setShowNewModal(true)}>New Import</Button> <Button onClick={() => setShowNewModal(true)}>New Import</Button>
+334 -28
View File
@@ -6,6 +6,9 @@ import {
getMonthRange, getMonthRange,
countWorkingDaysInOverlap, countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES, DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice, type AssignmentSlice,
} from "@planarchy/engine"; } from "@planarchy/engine";
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared"; import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared";
@@ -141,9 +144,14 @@ export const computationGraphRouter = createTRPCRouter({
select: { startDate: true, endDate: true, type: true, isHalfDay: true }, select: { startDate: true, endDate: true, type: true, isHalfDay: true },
}); });
// Build absence dates for SAH (ISO strings) // Build absence dates for SAH (ISO strings), separating public holidays
const publicHolidayStrings: string[] = [];
const absenceDateStrings: string[] = []; const absenceDateStrings: string[] = [];
const absenceDays: AbsenceDay[] = []; const absenceDays: AbsenceDay[] = [];
let halfDayCount = 0;
let vacationDayCount = 0;
let sickDayCount = 0;
let publicHolidayCount = 0;
for (const v of vacations) { for (const v of vacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
@@ -156,12 +164,21 @@ export const computationGraphRouter = createTRPCRouter({
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const : v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const; : "VACATION" as const;
while (cursor <= endNorm) { while (cursor <= endNorm) {
absenceDateStrings.push(cursor.toISOString().slice(0, 10)); 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({ absenceDays.push({
date: new Date(cursor), date: new Date(cursor),
type: triggerType, type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}), ...(v.isHalfDay ? { isHalfDay: true } : {}),
}); });
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1); cursor.setUTCDate(cursor.getUTCDate() + 1);
} }
} }
@@ -187,7 +204,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte, fte: resource.fte,
periodStart: monthStart, periodStart: monthStart,
periodEnd: monthEnd, periodEnd: monthEnd,
publicHolidays: [], publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings, absenceDays: absenceDateStrings,
}); });
@@ -301,6 +318,9 @@ export const computationGraphRouter = createTRPCRouter({
const avgHoursPerDay = assignments.length > 0 const avgHoursPerDay = assignments.length > 0
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0; : 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
}, 0);
// Format weekly availability for display // Format weekly availability for display
const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"]; const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"];
@@ -311,30 +331,46 @@ export const computationGraphRouter = createTRPCRouter({
? `${weekdayValues[0]}h/day` ? `${weekdayValues[0]}h/day`
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" "); : 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[] = [ const nodes: GraphNode[] = [
// INPUT // INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0), 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), 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.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.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.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}`, 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.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.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 // SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1), 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.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.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences falling on working days", 1), n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - absences"), n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day", 2, "Σ(dailyHours × FTE) / netDays"), 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"), n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
// ALLOCATION // ALLOCATION
n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(effectiveHours/day)"), 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.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)"), 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 ? [ ...(hasRulesEffect ? [
n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"), 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"), n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"),
@@ -345,11 +381,29 @@ export const computationGraphRouter = createTRPCRouter({
n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1), 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.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"), 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 // 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.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"), 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.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.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"), 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"),
@@ -361,10 +415,15 @@ export const computationGraphRouter = createTRPCRouter({
const links: GraphLink[] = [ const links: GraphLink[] = [
// INPUT → SAH // INPUT → SAH
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), 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.calendarDays", "sah.grossWorkingDays", " weekends", 2),
l("sah.weekendDays", "sah.grossWorkingDays", "", 1), l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1), l("input.absences", "sah.absenceDays", "∩ workdays", 1),
l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2), l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2),
l("sah.publicHolidayDays", "sah.netWorkingDays", "", 1),
l("sah.absenceDays", "sah.netWorkingDays", "", 1), l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1), l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2), l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
@@ -376,8 +435,14 @@ export const computationGraphRouter = createTRPCRouter({
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2), l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1), l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1),
l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2), 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("input.hoursPerDay", "alloc.totalHours", "× workdays", 1),
l("alloc.dailyCostCents", "alloc.totalCostCents", "Σ", 2), 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) // RULES → ALLOCATION (if absences)
...(absenceDays.length > 0 ? [ ...(absenceDays.length > 0 ? [
@@ -392,12 +457,20 @@ export const computationGraphRouter = createTRPCRouter({
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1), l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2), l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1), l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
...(absenceDays.length > 0 ? [
l("rules.costEffect", "rules.costReduction", "reduce %", 1),
] : []),
] : []), ] : []),
// ALLOCATION + SAH → CHARGEABILITY // ALLOCATION + SAH → CHARGEABILITY
l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2), l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2),
l("chg.chgHours", "chg.chg", "÷ SAH", 2), l("chg.chgHours", "chg.chg", "÷ SAH", 2),
l("sah.sah", "chg.chg", "÷", 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("sah.sah", "chg.unassigned", " assigned ÷ SAH", 1),
l("chg.chgHours", "chg.unassigned", "SAH Σ", 1), l("chg.chgHours", "chg.unassigned", "SAH Σ", 1),
l("input.targetPct", "chg.target", "=", 1), l("input.targetPct", "chg.target", "=", 1),
@@ -421,7 +494,7 @@ export const computationGraphRouter = createTRPCRouter({
}), }),
/** /**
* Project View: Estimate, Commercial, Budget * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget
*/ */
getProjectData: controllerProcedure getProjectData: controllerProcedure
.input(z.object({ .input(z.object({
@@ -441,7 +514,7 @@ export const computationGraphRouter = createTRPCRouter({
}, },
}); });
// Load latest estimate version with demand lines // Load latest estimate version with demand lines + scope items
const estimate = await ctx.db.estimate.findFirst({ const estimate = await ctx.db.estimate.findFirst({
where: { projectId: input.projectId }, where: { projectId: input.projectId },
select: { select: {
@@ -454,11 +527,38 @@ export const computationGraphRouter = createTRPCRouter({
commercialTerms: true, commercialTerms: true,
demandLines: { demandLines: {
select: { select: {
id: true,
hours: true, hours: true,
costRateCents: true, costRateCents: true,
billRateCents: true, billRateCents: true,
costTotalCents: true, costTotalCents: true,
priceTotalCents: 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,
}, },
}, },
}, },
@@ -468,38 +568,66 @@ export const computationGraphRouter = createTRPCRouter({
}); });
const latestVersion = estimate?.versions[0]; 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 nodes: GraphNode[] = [];
const links: GraphLink[] = []; const links: GraphLink[] = [];
// Budget inputs // Budget + project inputs
const hasBudget = project.budgetCents > 0; const hasBudget = project.budgetCents > 0;
const hasDateRange = !!(project.startDate && project.endDate);
nodes.push( 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.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), 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) { if (latestVersion && latestVersion.demandLines.length > 0) {
const lines = latestVersion.demandLines; const lines = latestVersion.demandLines;
const totalHours = lines.reduce((s, dl) => s + dl.hours, 0); const summary = summarizeEstimateDemandLines(lines);
const totalCostCents = lines.reduce((s, dl) => s + dl.costTotalCents, 0); const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary;
const totalPriceCents = lines.reduce((s, dl) => s + dl.priceTotalCents, 0);
const marginCents = totalPriceCents - totalCostCents;
const marginPct = totalPriceCents > 0 ? (marginCents / totalPriceCents) * 100 : 0;
// Average rates // Average rates
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0; const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / 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( nodes.push(
n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0), 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.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), 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.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.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.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.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"), 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( links.push(
@@ -512,13 +640,88 @@ export const computationGraphRouter = createTRPCRouter({
l("est.totalCostCents", "est.marginCents", "", 2), l("est.totalCostCents", "est.marginCents", "", 2),
l("est.marginCents", "est.marginPercent", "÷ price × 100", 2), l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
l("est.totalPriceCents", "est.marginPercent", "÷", 1), 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),
] : []),
); );
// Commercial terms // ── EFFORT domain: scope items → demand line expansion ──
const terms = latestVersion.commercialTerms as { contingencyPercent?: number; discountPercent?: number } | null; const scopeItems = latestVersion.scopeItems ?? [];
if (terms && (terms.contingencyPercent || terms.discountPercent)) { if (scopeItems.length > 0) {
const contingencyPct = terms.contingencyPercent ?? 0; const totalFrameCount = scopeItems.reduce((s, si) => s + (si.frameCount ?? 0), 0);
const discountPct = terms.discountPercent ?? 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 contingencyCents = Math.round(totalCostCents * contingencyPct / 100);
const discountCents = Math.round(totalPriceCents * discountPct / 100); const discountCents = Math.round(totalPriceCents * discountPct / 100);
const adjCost = totalCostCents + contingencyCents; const adjCost = totalCostCents + contingencyCents;
@@ -527,8 +730,8 @@ export const computationGraphRouter = createTRPCRouter({
const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0; const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0;
nodes.push( nodes.push(
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage", 0), n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0),
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage", 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.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.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.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
@@ -552,9 +755,87 @@ export const computationGraphRouter = createTRPCRouter({
l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1), 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),
);
}
} }
// Budget status — always show allocation totals; remaining/utilization only when budget > 0 // ── 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({ const projectAllocs = await ctx.db.assignment.findMany({
where: { projectId: input.projectId }, where: { projectId: input.projectId },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
@@ -575,6 +856,9 @@ export const computationGraphRouter = createTRPCRouter({
project.endDate ?? new Date(), project.endDate ?? new Date(),
); );
// Total allocated hours for comparison
const totalAllocatedHours = projectAllocs.reduce((sum, pa) => sum + pa.hoursPerDay, 0);
nodes.push( nodes.push(
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"), 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.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
@@ -590,9 +874,12 @@ export const computationGraphRouter = createTRPCRouter({
hasBudget ? "Budget utilization" : "Cannot compute — no budget set", hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
3, hasBudget ? "allocated / budget × 100" : "needs budget"), 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.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( links.push(
l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1),
l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1),
l("budget.confirmedCents", "budget.allocatedCents", "+", 2), l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
l("budget.proposedCents", "budget.allocatedCents", "+", 2), l("budget.proposedCents", "budget.allocatedCents", "+", 2),
l("input.budgetCents", "budget.remainingCents", "", 2), l("input.budgetCents", "budget.remainingCents", "", 2),
@@ -602,6 +889,25 @@ export const computationGraphRouter = createTRPCRouter({
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1), l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
l("input.winProbability", "budget.weightedCents", "×", 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 { return {