From 8fdc83aea1aa9f725fd26194635778e644e7672e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 10:41:24 +0200 Subject: [PATCH] refactor(api): modularize computation graph resource snapshot --- .../computation-graph-resource-budget.ts | 83 +++ .../computation-graph-resource-graph.ts | 318 ++++++++++ .../src/router/computation-graph-resource.ts | 391 ++++++++++++ packages/api/src/router/computation-graph.ts | 578 +----------------- 4 files changed, 794 insertions(+), 576 deletions(-) create mode 100644 packages/api/src/router/computation-graph-resource-budget.ts create mode 100644 packages/api/src/router/computation-graph-resource-graph.ts create mode 100644 packages/api/src/router/computation-graph-resource.ts diff --git a/packages/api/src/router/computation-graph-resource-budget.ts b/packages/api/src/router/computation-graph-resource-budget.ts new file mode 100644 index 0000000..fbe28ae --- /dev/null +++ b/packages/api/src/router/computation-graph-resource-budget.ts @@ -0,0 +1,83 @@ +import { computeBudgetStatus } from "@capakraken/engine"; +import type { TRPCContext } from "../trpc.js"; +import { fmtEur } from "../lib/format-utils.js"; +import { type GraphLink, type GraphNode, l, n } from "./computation-graph-shared.js"; + +type ResourceBudgetProject = { + id: string; + name: string; + budgetCents: number | null; + winProbability: number; +}; + +type ResourceBudgetAssignment = { + project: ResourceBudgetProject; +}; + +export type ResourceBudgetGraph = { + nodes: GraphNode[]; + links: GraphLink[]; +}; + +export async function readResourceBudgetGraph( + db: TRPCContext["db"], + assignments: ResourceBudgetAssignment[], + monthStart: Date, + monthEnd: Date, +): Promise { + const budgetProject = assignments.find((assignment) => ( + assignment.project.budgetCents != null && assignment.project.budgetCents > 0 + ))?.project; + + if (!budgetProject?.budgetCents) { + return { nodes: [], links: [] }; + } + + const projectAllocs = await 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((projectAllocation) => ({ + status: projectAllocation.status as unknown as string, + dailyCostCents: projectAllocation.dailyCostCents, + startDate: projectAllocation.startDate, + endDate: projectAllocation.endDate, + hoursPerDay: projectAllocation.hoursPerDay, + })) as Parameters[2], + monthStart, + monthEnd, + ); + + return { + nodes: [ + 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"), + ], + links: [ + 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), + ], + }; +} diff --git a/packages/api/src/router/computation-graph-resource-graph.ts b/packages/api/src/router/computation-graph-resource-graph.ts new file mode 100644 index 0000000..ba77b1d --- /dev/null +++ b/packages/api/src/router/computation-graph-resource-graph.ts @@ -0,0 +1,318 @@ +import type { WeekdayAvailability } from "@capakraken/shared"; +import { fmtEur } from "../lib/format-utils.js"; +import { type GraphLink, type GraphNode, fmtPct, fmtNum, l, n } from "./computation-graph-shared.js"; + +type ResourceGraphResolvedHoliday = { + date: string; + name: string; + scope: "COUNTRY" | "STATE" | "CITY"; + calendarName: string | null; + sourceType: string | null; +}; + +type ResourceAssignmentBreakdown = { + id: string; + projectId: string; + projectName: string; + projectCode: string; + status: string; + bookedHours: number; +}; + +type ResourceGraphInputResource = { + displayName: string; + eid: string; + fte: number; + lcrCents: number; + country: { + code: string; + name: string; + } | null; + federalState: string | null; + metroCity: { + name: string; + } | null; + managementLevelGroup: { + name: string; + } | null; +}; + +type ResourceForecastSummary = { + chg: number; + bd: number; + mdi: number; + mo: number; + pdr: number; + absence: number; + unassigned: number; +}; + +type ResourceGraphPresentationInput = { + month: string; + resource: ResourceGraphInputResource; + dailyHours: number; + scheduleRules: unknown | null; + targetPct: number; + weeklyAvailability: WeekdayAvailability; + holidayScopeSummary: string; + holidayExamples: string; + holidayScopeBreakdown: Record; + calcRulesCount: number; + assignmentCount: number; + absenceCount: number; + vacationDayCount: number; + sickDayCount: number; + halfDayCount: number; + publicHolidayCount: number; + publicHolidayWorkdayCount: number; + publicHolidayHoursDeduction: number; + absenceHoursDeduction: number; + sahCalendarDays: number; + sahWeekendDays: number; + baseWorkingDays: number; + effectiveWorkingDays: number; + baseAvailableHours: number; + effectiveAvailableHours: number; + effectiveHoursPerWorkingDay: number; + totalWorkingDaysInMonth: number; + totalAllocHours: number; + totalAllocCostCents: number; + totalChargeableHours: number; + totalProjectCostCents: number; + hasRulesEffect: boolean; + dailyCostCents: number; + avgHoursPerDay: number; + utilizationPct: number; + forecast: ResourceForecastSummary; + chargeableHours: number; + budgetNodes: GraphNode[]; + budgetLinks: GraphLink[]; + resolvedHolidays: ResourceGraphResolvedHoliday[]; + assignmentBreakdown: ResourceAssignmentBreakdown[]; + absenceDayEquivalent: number; +}; + +function describeWeeklyAvailability(availability: WeekdayAvailability): { + totalHours: number; + label: string; +} { + const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"]; + const weekdayValues = [ + availability.monday, + availability.tuesday, + availability.wednesday, + availability.thursday, + availability.friday, + ]; + const totalHours = weekdayValues.reduce((sum, value) => sum + value, 0); + const allSame = weekdayValues.every((value) => value === weekdayValues[0]); + + return { + totalHours, + label: allSame + ? `${weekdayValues[0]}h/day` + : weekdayLabels.map((day, index) => `${day}:${weekdayValues[index]}`).join(" "), + }; +} + +export function buildResourceGraphSnapshot(input: ResourceGraphPresentationInput) { + const { totalHours: weeklyTotalHours, label: availabilityLabel } = describeWeeklyAvailability(input.weeklyAvailability); + const hasScheduleRules = !!input.scheduleRules; + + const nodes: GraphNode[] = [ + n("input.fte", "FTE", fmtNum(input.resource.fte, 2), "ratio", "INPUT", "Resource FTE factor", 0), + n("input.country", "Country", input.resource.country?.name ?? input.resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0), + n("input.state", "State", input.resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0), + n("input.city", "City", input.resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0), + n("input.holidayContext", "Holiday Context", input.holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0), + n("input.holidayExamples", "Holiday Dates", input.holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${input.holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${input.holidayScopeBreakdown.STATE ?? 0}, CITY ${input.holidayScopeBreakdown.CITY ?? 0}`, 0), + n("input.dailyHours", "Country Hours", `${input.dailyHours} h`, "hours", "INPUT", `Base daily working hours (${input.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(input.resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0), + n("input.hoursPerDay", "Hours/Day", fmtNum(input.avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0), + n("input.absences", "Absences", `${input.absenceCount}`, "count", "INPUT", `Absence days in ${input.month} (${input.vacationDayCount} vacation, ${input.sickDayCount} sick${input.halfDayCount > 0 ? `, ${input.halfDayCount} half-day` : ""})`, 0), + n("input.publicHolidays", "Public Holidays", `${input.publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${input.publicHolidayWorkdayCount} hit configured working days`, 0), + n("input.calcRules", "Active Rules", `${input.calcRulesCount}`, "count", "INPUT", "Active calculation rules", 0), + n("input.targetPct", "Target", fmtPct(input.targetPct), "%", "INPUT", `Chargeability target (${input.resource.managementLevelGroup?.name ?? "legacy"})`, 0), + n("input.assignmentCount", "Assignments", `${input.assignmentCount}`, "count", "INPUT", `Active assignments in ${input.month}`, 0), + + n("sah.calendarDays", "Calendar Days", `${input.sahCalendarDays}`, "days", "SAH", "Total calendar days in period", 1), + n("sah.weekendDays", "Weekend Days", `${input.sahWeekendDays}`, "days", "SAH", "Saturday + Sunday count", 1), + n("sah.grossWorkingDays", "Gross Work Days", `${input.baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"), + n("sah.baseHours", "Base Hours", fmtNum(input.baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"), + n("sah.publicHolidayDays", "Holiday Ded.", `${input.publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1), + n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(input.publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"), + n("sah.absenceDays", "Absence Ded.", `${input.absenceCount}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1), + n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(input.absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"), + n("sah.netWorkingDays", "Net Work Days", `${input.effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"), + n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(input.effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"), + n("sah.sah", "SAH", fmtNum(input.effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"), + + n("alloc.workingDays", "Work Days", `${input.totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"), + n("alloc.totalHours", "Total Hours", fmtNum(input.totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"), + n("alloc.dailyCostCents", "Daily Cost", fmtEur(input.dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"), + n("alloc.totalCostCents", "Total Cost", fmtEur(input.totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"), + n("alloc.utilizationPct", "Utilization", `${input.utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"), + ...(input.hasRulesEffect + ? [ + n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(input.totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"), + n("alloc.projectCostCents", "Project Cost", fmtEur(input.totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"), + ] + : []), + + ...(input.absenceCount > 0 + ? [ + n("rules.activeRules", "Matched Rules", `${input.calcRulesCount} rules`, "—", "RULES", "Rules evaluated for absence days", 1), + n("rules.costEffect", "Cost Effect", input.hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"), + n("rules.chgEffect", "Chg Effect", input.hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"), + ...(input.hasRulesEffect + ? [n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100")] + : []), + ] + : []), + + n("chg.chgHours", "Chg Hours", fmtNum(input.chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"), + n("chg.chg", "Chargeability", fmtPct(input.forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"), + ...(input.forecast.bd > 0 + ? [n("chg.bd", "BD Ratio", fmtPct(input.forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(input.forecast.bd * input.effectiveAvailableHours)}h`, 3, "bdHours / SAH")] + : []), + ...(input.forecast.mdi > 0 + ? [n("chg.mdi", "MD&I Ratio", fmtPct(input.forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(input.forecast.mdi * input.effectiveAvailableHours)}h`, 3, "mdiHours / SAH")] + : []), + ...(input.forecast.mo > 0 + ? [n("chg.mo", "M&O Ratio", fmtPct(input.forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(input.forecast.mo * input.effectiveAvailableHours)}h`, 3, "moHours / SAH")] + : []), + ...(input.forecast.pdr > 0 + ? [n("chg.pdr", "PD&R Ratio", fmtPct(input.forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(input.forecast.pdr * input.effectiveAvailableHours)}h`, 3, "pdrHours / SAH")] + : []), + ...(input.forecast.absence > 0 + ? [n("chg.absence", "Absence Ratio", fmtPct(input.forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(input.forecast.absence * input.effectiveAvailableHours)}h`, 3, "absenceHours / SAH")] + : []), + n("chg.unassigned", "Unassigned", fmtPct(input.forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(input.forecast.unassigned * input.effectiveAvailableHours)}h of ${fmtNum(input.effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"), + n("chg.target", "Target", fmtPct(input.targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3), + n("chg.gap", "Gap to Target", `${input.forecast.chg - input.targetPct >= 0 ? "+" : ""}${((input.forecast.chg - input.targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(input.forecast.chg)}) vs. target (${fmtPct(input.targetPct)})`, 3, "chargeability − target"), + + ...input.budgetNodes, + ]; + + const links: GraphLink[] = [ + l("input.country", "input.holidayContext", "holiday base", 1), + l("input.state", "input.holidayContext", "regional scope", 1), + l("input.city", "input.holidayContext", "local scope", 1), + l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1), + l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), + l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2), + l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2), + l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2), + l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2), + l("input.absences", "sah.absenceHours", "remove absence fractions", 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", "− holiday/absence days", 2), + l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1), + l("sah.absenceDays", "sah.netWorkingDays", "−", 1), + l("sah.baseHours", "sah.sah", "start from base capacity", 2), + l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2), + l("sah.absenceHours", "sah.sah", "− absence hours", 2), + l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1), + l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1), + + 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), + + ...(input.absenceCount > 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), + ] + : []), + + ...(input.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), + ...(input.absenceCount > 0 ? [l("rules.costEffect", "rules.costReduction", "reduce %", 1)] : []), + ] + : []), + + l(input.hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2), + l("chg.chgHours", "chg.chg", "÷ SAH", 2), + l("sah.sah", "chg.chg", "÷", 2), + ...(input.forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []), + ...(input.forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []), + ...(input.forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []), + ...(input.forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []), + ...(input.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), + + ...input.budgetLinks, + ]; + + return { + nodes, + links, + meta: { + resourceName: input.resource.displayName, + resourceEid: input.resource.eid, + month: input.month, + assignmentCount: input.assignmentCount, + countryCode: input.resource.country?.code ?? null, + countryName: input.resource.country?.name ?? null, + federalState: input.resource.federalState ?? null, + metroCityName: input.resource.metroCity?.name ?? null, + resolvedHolidays: input.resolvedHolidays.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + })), + factors: { + fte: input.resource.fte, + targetPct: input.targetPct * 100, + weeklyAvailability: input.weeklyAvailability, + baseWorkingDays: input.baseWorkingDays, + effectiveWorkingDays: input.effectiveWorkingDays, + baseAvailableHours: input.baseAvailableHours, + effectiveAvailableHours: input.effectiveAvailableHours, + publicHolidayCount: input.publicHolidayCount, + publicHolidayWorkdayCount: input.publicHolidayWorkdayCount, + publicHolidayHoursDeduction: input.publicHolidayHoursDeduction, + absenceDayCount: input.absenceCount, + absenceDayEquivalent: input.absenceDayEquivalent, + absenceHoursDeduction: input.absenceHoursDeduction, + bookedHours: input.totalAllocHours, + chargeableHours: input.chargeableHours, + chargeabilityPct: input.forecast.chg * 100, + utilizationPct: input.utilizationPct, + }, + assignments: input.assignmentBreakdown.map((assignment) => ({ + ...assignment, + bookedHours: Number(assignment.bookedHours.toFixed(1)), + })), + }, + }; +} diff --git a/packages/api/src/router/computation-graph-resource.ts b/packages/api/src/router/computation-graph-resource.ts new file mode 100644 index 0000000..4de71e6 --- /dev/null +++ b/packages/api/src/router/computation-graph-resource.ts @@ -0,0 +1,391 @@ +import { + calculateSAH, + calculateAllocation, + deriveResourceForecast, + getMonthRange, + DEFAULT_CALCULATION_RULES, + type AssignmentSlice, +} from "@capakraken/engine"; +import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; +import { VacationStatus } from "@capakraken/db"; +import type { TRPCContext } from "../trpc.js"; +import { fmtEur } from "../lib/format-utils.js"; +import { + asHolidayResolverDb, + collectHolidayAvailability, + getResolvedCalendarHolidays, +} from "../lib/holiday-availability.js"; +import { + calculateEffectiveAvailableHours, + countEffectiveWorkingDays, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { readResourceBudgetGraph } from "./computation-graph-resource-budget.js"; +import { buildResourceGraphSnapshot } from "./computation-graph-resource-graph.js"; + +type ResourceGraphInput = { + resourceId: string; + month: string; +}; + +function getAvailabilityHoursForDate( + availability: WeekdayAvailability, + date: Date, +): number { + const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; + return availability[dayKey] ?? 0; +} + +function sumAvailabilityHoursForDates( + availability: WeekdayAvailability, + dates: Date[], +): number { + return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); +} + +export async function readResourceGraphSnapshot( + ctx: { db: TRPCContext["db"] }, + input: ResourceGraphInput, +) { + const [year, month] = input.month.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + + const resource = await ctx.db.resource.findUniqueOrThrow({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + countryId: true, + federalState: true, + metroCityId: true, + availability: true, + country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, + metroCity: { select: { id: true, name: 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); + + 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, + }; + + 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 } }, + }, + }, + }, + }); + + 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 }, + }); + const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: monthStart, + periodEnd: monthEnd, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }); + const holidayAvailability = collectHolidayAvailability({ + vacations, + periodStart: monthStart, + periodEnd: monthEnd, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name, + resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), + }); + const publicHolidayStrings = holidayAvailability.publicHolidayStrings; + const absenceDateStrings = holidayAvailability.absenceDateStrings; + const absenceDays = holidayAvailability.absenceDays; + const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length; + const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length; + const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length; + const publicHolidayCount = resolvedHolidays.length; + const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + return sum + (absence.isHalfDay ? 0.5 : 1); + }, 0); + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability: weeklyAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + monthStart, + monthEnd, + ); + const availabilityContext = contexts.get(resource.id); + + 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 + } + + const sahResult = calculateSAH({ + dailyWorkingHours: dailyHours, + scheduleRules, + fte: resource.fte, + periodStart: monthStart, + periodEnd: monthEnd, + publicHolidays: publicHolidayStrings, + absenceDays: absenceDateStrings, + }); + const baseWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); + const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( + count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( + weeklyAvailability, + publicHolidayDates, + ); + const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); + return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); + }, 0); + const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0 + ? effectiveAvailableHours / effectiveWorkingDays + : 0; + const holidayScopeSummary = [ + resource.country?.code ?? "—", + resource.federalState ?? "—", + resource.metroCity?.name ?? "—", + ].join(" / "); + const holidayExamples = resolvedHolidays.length > 0 + ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ") + : "none"; + const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => { + counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1; + return counts; + }, {}); + + const slices: AssignmentSlice[] = []; + const assignmentBreakdown: Array<{ + id: string; + projectId: string; + projectName: string; + projectCode: string; + status: string; + bookedHours: number; + }> = []; + let totalAllocHours = 0; + let totalAllocCostCents = 0; + let totalChargeableHours = 0; + let totalProjectCostCents = 0; + let hasRulesEffect = false; + + for (const a of assignments) { + 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, + }); + if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue; + + totalAllocHours += calcResult.totalHours; + totalAllocCostCents += calcResult.totalCostCents; + assignmentBreakdown.push({ + id: a.id, + projectId: a.project.id, + projectName: a.project.name, + projectCode: a.project.shortCode, + status: a.status, + bookedHours: calcResult.totalHours, + }); + 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: calcResult.workingDays, + categoryCode, + ...(calcResult.totalChargeableHours !== undefined + ? { totalChargeableHours: calcResult.totalChargeableHours } + : {}), + }); + } + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetPct, + assignments: slices, + sah: effectiveAvailableHours, + }); + const { nodes: budgetNodes, links: budgetLinks } = await readResourceBudgetGraph( + ctx.db, + assignments, + monthStart, + monthEnd, + ); + + 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) => { + const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: a.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + }); + return sum + calcResult.workingDays; + }, 0); + + const utilizationPct = effectiveAvailableHours > 0 + ? (totalAllocHours / effectiveAvailableHours) * 100 + : 0; + const chargeableHours = forecast.chg * effectiveAvailableHours; + return buildResourceGraphSnapshot({ + month: input.month, + resource, + dailyHours, + scheduleRules, + targetPct, + weeklyAvailability, + holidayScopeSummary, + holidayExamples, + holidayScopeBreakdown, + calcRulesCount: calcRules.length, + assignmentCount: assignments.length, + absenceCount: absenceDateStrings.length, + vacationDayCount, + sickDayCount, + halfDayCount, + publicHolidayCount, + publicHolidayWorkdayCount, + publicHolidayHoursDeduction, + absenceHoursDeduction, + sahCalendarDays: sahResult.calendarDays, + sahWeekendDays: sahResult.weekendDays, + baseWorkingDays, + effectiveWorkingDays, + baseAvailableHours, + effectiveAvailableHours, + effectiveHoursPerWorkingDay, + totalWorkingDaysInMonth, + totalAllocHours, + totalAllocCostCents, + totalChargeableHours, + totalProjectCostCents, + hasRulesEffect, + dailyCostCents, + avgHoursPerDay, + utilizationPct, + forecast, + chargeableHours, + budgetNodes, + budgetLinks, + resolvedHolidays: resolvedHolidays.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType ?? null, + })), + assignmentBreakdown, + absenceDayEquivalent, + }); +} diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index 450c668..f1a141a 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -1,47 +1,11 @@ -import { - calculateSAH, - calculateAllocation, - deriveResourceForecast, - computeBudgetStatus, - getMonthRange, - DEFAULT_CALCULATION_RULES, - type AssignmentSlice, -} from "@capakraken/engine"; -import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; -import { VacationStatus } from "@capakraken/db"; import { z } from "zod"; import { createTRPCRouter, controllerProcedure, type TRPCContext } from "../trpc.js"; -import { fmtEur } from "../lib/format-utils.js"; -import { - asHolidayResolverDb, - collectHolidayAvailability, - getResolvedCalendarHolidays, -} from "../lib/holiday-availability.js"; -import { - calculateEffectiveAvailableHours, - countEffectiveWorkingDays, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js"; import { readProjectGraphSnapshot } from "./computation-graph-project.js"; -import { type GraphLink, type GraphNode, fmtPct, fmtNum, l, n } from "./computation-graph-shared.js"; +import { readResourceGraphSnapshot } from "./computation-graph-resource.js"; +import { type GraphLink, type GraphNode } from "./computation-graph-shared.js"; export type { GraphLink, GraphNode } from "./computation-graph-shared.js"; -function getAvailabilityHoursForDate( - availability: WeekdayAvailability, - date: Date, -): number { - const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; - return availability[dayKey] ?? 0; -} - -function sumAvailabilityHoursForDates( - availability: WeekdayAvailability, - dates: Date[], -): number { - return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); -} - const resourceGraphInputSchema = z.object({ resourceId: z.string(), month: z.string().regex(/^\d{4}-\d{2}$/), @@ -77,541 +41,3 @@ export const computationGraphRouter = createTRPCRouter({ .input(projectGraphInputSchema) .query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)), }); - -async function readResourceGraphSnapshot( - ctx: { db: TRPCContext["db"] }, - input: z.infer, -) { - const [year, month] = input.month.split("-").map(Number) as [number, number]; - const { start: monthStart, end: monthEnd } = getMonthRange(year, month); - - const resource = await ctx.db.resource.findUniqueOrThrow({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - eid: true, - fte: true, - lcrCents: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - availability: true, - country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, - metroCity: { select: { id: true, name: 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); - - 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, - }; - - 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 } }, - }, - }, - }, - }); - - 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 }, - }); - const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: monthStart, - periodEnd: monthEnd, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }); - const holidayAvailability = collectHolidayAvailability({ - vacations, - periodStart: monthStart, - periodEnd: monthEnd, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name, - resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), - }); - const publicHolidayStrings = holidayAvailability.publicHolidayStrings; - const absenceDateStrings = holidayAvailability.absenceDateStrings; - const absenceDays = holidayAvailability.absenceDays; - const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length; - const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length; - const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length; - const publicHolidayCount = resolvedHolidays.length; - const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - return sum + (absence.isHalfDay ? 0.5 : 1); - }, 0); - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability: weeklyAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - monthStart, - monthEnd, - ); - const availabilityContext = contexts.get(resource.id); - - 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 - } - - const sahResult = calculateSAH({ - dailyWorkingHours: dailyHours, - scheduleRules, - fte: resource.fte, - periodStart: monthStart, - periodEnd: monthEnd, - publicHolidays: publicHolidayStrings, - absenceDays: absenceDateStrings, - }); - const baseWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); - const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( - count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) - ), 0); - const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( - weeklyAvailability, - publicHolidayDates, - ); - const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); - return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); - }, 0); - const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0 - ? effectiveAvailableHours / effectiveWorkingDays - : 0; - const holidayScopeSummary = [ - resource.country?.code ?? "—", - resource.federalState ?? "—", - resource.metroCity?.name ?? "—", - ].join(" / "); - const holidayExamples = resolvedHolidays.length > 0 - ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ") - : "none"; - const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => { - counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1; - return counts; - }, {}); - - const slices: AssignmentSlice[] = []; - const assignmentBreakdown: Array<{ - id: string; - projectId: string; - projectName: string; - projectCode: string; - status: string; - bookedHours: number; - }> = []; - let totalAllocHours = 0; - let totalAllocCostCents = 0; - let totalChargeableHours = 0; - let totalProjectCostCents = 0; - let hasRulesEffect = false; - - for (const a of assignments) { - 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, - }); - if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue; - - totalAllocHours += calcResult.totalHours; - totalAllocCostCents += calcResult.totalCostCents; - assignmentBreakdown.push({ - id: a.id, - projectId: a.project.id, - projectName: a.project.name, - projectCode: a.project.shortCode, - status: a.status, - bookedHours: calcResult.totalHours, - }); - 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: calcResult.workingDays, - categoryCode, - ...(calcResult.totalChargeableHours !== undefined - ? { totalChargeableHours: calcResult.totalChargeableHours } - : {}), - }); - } - - const forecast = deriveResourceForecast({ - fte: resource.fte, - targetPercentage: targetPct, - assignments: slices, - sah: effectiveAvailableHours, - }); - - 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) { - 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), - ]; - } - - 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) => { - const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); - const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); - const calcResult = calculateAllocation({ - lcrCents: resource.lcrCents, - hoursPerDay: a.hoursPerDay, - startDate: overlapStart, - endDate: overlapEnd, - availability: weeklyAvailability, - absenceDays, - calculationRules: calcRules, - }); - return sum + calcResult.workingDays; - }, 0); - - 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(" "); - - const utilizationPct = effectiveAvailableHours > 0 - ? (totalAllocHours / effectiveAvailableHours) * 100 - : 0; - const chargeableHours = forecast.chg * effectiveAvailableHours; - const hasScheduleRules = !!scheduleRules; - - const nodes: GraphNode[] = [ - n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", "Resource FTE factor", 0), - n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0), - n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0), - n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0), - n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0), - n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 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", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 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), - - 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", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"), - n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"), - n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1), - n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"), - n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1), - n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"), - n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"), - n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"), - n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"), - - 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"), - ] : []), - - ...(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"), - ] : []), - ] : []), - - n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × 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 * effectiveAvailableHours)}h`, 3, "bdHours / SAH"), - ] : []), - ...(forecast.mdi > 0 ? [ - n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"), - ] : []), - ...(forecast.mo > 0 ? [ - n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"), - ] : []), - ...(forecast.pdr > 0 ? [ - n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"), - ] : []), - ...(forecast.absence > 0 ? [ - n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"), - ] : []), - n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}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"), - - ...budgetNodes, - ]; - - const links: GraphLink[] = [ - l("input.country", "input.holidayContext", "holiday base", 1), - l("input.state", "input.holidayContext", "regional scope", 1), - l("input.city", "input.holidayContext", "local scope", 1), - l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1), - l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), - l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2), - l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2), - l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2), - l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2), - l("input.absences", "sah.absenceHours", "remove absence fractions", 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", "− holiday/absence days", 2), - l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1), - l("sah.absenceDays", "sah.netWorkingDays", "−", 1), - l("sah.baseHours", "sah.sah", "start from base capacity", 2), - l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2), - l("sah.absenceHours", "sah.sah", "− absence hours", 2), - l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1), - l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1), - - 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), - - ...(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), - ] : []), - ] : []), - - 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), - - ...budgetLinks, - ]; - - return { - nodes, - links, - meta: { - resourceName: resource.displayName, - resourceEid: resource.eid, - month: input.month, - assignmentCount: assignments.length, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - resolvedHolidays: resolvedHolidays.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - })), - factors: { - fte: resource.fte, - targetPct: targetPct * 100, - weeklyAvailability, - baseWorkingDays, - effectiveWorkingDays, - baseAvailableHours, - effectiveAvailableHours, - publicHolidayCount, - publicHolidayWorkdayCount, - publicHolidayHoursDeduction, - absenceDayCount: absenceDateStrings.length, - absenceDayEquivalent, - absenceHoursDeduction, - bookedHours: totalAllocHours, - chargeableHours, - chargeabilityPct: forecast.chg * 100, - utilizationPct, - }, - assignments: assignmentBreakdown.map((assignment) => ({ - ...assignment, - bookedHours: Number(assignment.bookedHours.toFixed(1)), - })), - }, - }; -}