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
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage V2 data imports from Dispo workbooks
Manage Dispo imports
</p>
</div>
<Button onClick={() => setShowNewModal(true)}>New Import</Button>
+334 -28
View File
@@ -6,6 +6,9 @@ import {
getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice,
} from "@planarchy/engine";
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 },
});
// Build absence dates for SAH (ISO strings)
// 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()));
@@ -156,12 +164,21 @@ export const computationGraphRouter = createTRPCRouter({
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
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({
date: new Date(cursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
@@ -187,7 +204,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [],
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
@@ -301,6 +318,9 @@ export const computationGraphRouter = createTRPCRouter({
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"];
@@ -311,30 +331,46 @@ export const computationGraphRouter = createTRPCRouter({
? `${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}`, 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.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day", 2, "Σ(dailyHours × FTE) / netDays"),
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.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.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 ? [
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"),
@@ -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.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
// 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"),
@@ -361,10 +415,15 @@ export const computationGraphRouter = createTRPCRouter({
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),
@@ -376,8 +435,14 @@ export const computationGraphRouter = createTRPCRouter({
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", "Σ", 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)
...(absenceDays.length > 0 ? [
@@ -392,12 +457,20 @@ export const computationGraphRouter = createTRPCRouter({
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),
@@ -421,7 +494,7 @@ export const computationGraphRouter = createTRPCRouter({
}),
/**
* Project View: Estimate, Commercial, Budget
* Project View: Estimate, Commercial, Experience, Effort, Spread, Budget
*/
getProjectData: controllerProcedure
.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({
where: { projectId: input.projectId },
select: {
@@ -454,11 +527,38 @@ export const computationGraphRouter = createTRPCRouter({
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,
},
},
},
@@ -468,38 +568,66 @@ export const computationGraphRouter = createTRPCRouter({
});
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 inputs
// 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 totalHours = lines.reduce((s, dl) => s + dl.hours, 0);
const totalCostCents = lines.reduce((s, dl) => s + dl.costTotalCents, 0);
const totalPriceCents = lines.reduce((s, dl) => s + dl.priceTotalCents, 0);
const marginCents = totalPriceCents - totalCostCents;
const marginPct = totalPriceCents > 0 ? (marginCents / totalPriceCents) * 100 : 0;
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(
@@ -512,13 +640,88 @@ export const computationGraphRouter = createTRPCRouter({
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),
] : []),
);
// Commercial terms
const terms = latestVersion.commercialTerms as { contingencyPercent?: number; discountPercent?: number } | null;
if (terms && (terms.contingencyPercent || terms.discountPercent)) {
const contingencyPct = terms.contingencyPercent ?? 0;
const discountPct = terms.discountPercent ?? 0;
// ── 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;
@@ -527,8 +730,8 @@ export const computationGraphRouter = createTRPCRouter({
const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0;
nodes.push(
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage", 0),
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage", 0),
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"),
@@ -552,9 +755,87 @@ export const computationGraphRouter = createTRPCRouter({
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 — always show allocation totals; remaining/utilization only when budget > 0
// ── Budget status ──
const projectAllocs = await ctx.db.assignment.findMany({
where: { projectId: input.projectId },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
@@ -575,6 +856,9 @@ export const computationGraphRouter = createTRPCRouter({
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)"),
@@ -590,9 +874,12 @@ export const computationGraphRouter = createTRPCRouter({
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),
@@ -602,6 +889,25 @@ export const computationGraphRouter = createTRPCRouter({
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 {