refactor(api): modularize computation graph resource snapshot
This commit is contained in:
@@ -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<ResourceBudgetGraph> {
|
||||||
|
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<typeof computeBudgetStatus>[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),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<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)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Record<string, number>>((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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { z } from "zod";
|
||||||
import { createTRPCRouter, controllerProcedure, type TRPCContext } from "../trpc.js";
|
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 { createComputationGraphDetailProcedures } from "./computation-graph-detail.js";
|
||||||
import { readProjectGraphSnapshot } from "./computation-graph-project.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";
|
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({
|
const resourceGraphInputSchema = z.object({
|
||||||
resourceId: z.string(),
|
resourceId: z.string(),
|
||||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
@@ -77,541 +41,3 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
.input(projectGraphInputSchema)
|
.input(projectGraphInputSchema)
|
||||||
.query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)),
|
.query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function readResourceGraphSnapshot(
|
|
||||||
ctx: { db: TRPCContext["db"] },
|
|
||||||
input: z.infer<typeof resourceGraphInputSchema>,
|
|
||||||
) {
|
|
||||||
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<Record<string, number>>((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<typeof computeBudgetStatus>[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)),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user