Files
CapaKraken/packages/api/src/router/computation-graph-resource-graph.ts
T

319 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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<string, number>;
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)),
})),
},
};
}