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

392 lines
13 KiB
TypeScript

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