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[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(); 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 | 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[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, }, }; }), });