refactor(api): share staffing capacity summaries

This commit is contained in:
2026-03-31 22:45:00 +02:00
parent 64111a9013
commit 3f9ae29e01
3 changed files with 177 additions and 166 deletions
@@ -1,21 +1,11 @@
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared"; import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js"; import { fmtEur } from "../lib/format-utils.js";
import { planningReadProcedure, requirePermission } from "../trpc.js"; import { planningReadProcedure, requirePermission } from "../trpc.js";
import { import { createDateRange, round1, toIsoDate } from "./staffing-shared.js";
averagePerWorkingDay, import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js";
createDateRange,
getBaseDayAvailability,
round1,
toIsoDate,
} from "./staffing-shared.js";
type BestProjectResourceRankingMode = type BestProjectResourceRankingMode =
| "lowest_lcr" | "lowest_lcr"
@@ -194,44 +184,14 @@ async function queryBestProjectResource(
const candidates = resources.map((resource) => { const candidates = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability; const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id); const context = contexts.get(resource.id);
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context: undefined,
});
const workingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context: undefined,
});
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const assignments = assignmentsByResourceId.get(resource.id) ?? []; const assignments = assignmentsByResourceId.get(resource.id) ?? [];
const bookedHours = assignments.reduce( const capacity = buildResourceCapacitySummary({
(sum, assignment) => availability,
sum + calculateEffectiveBookedHours({ periodStart: input.startDate,
availability, periodEnd: input.endDate,
startDate: assignment.startDate, context,
endDate: assignment.endDate, bookings: assignments,
hoursPerDay: assignment.hoursPerDay, });
periodStart: input.startDate,
periodEnd: input.endDate,
context,
}),
0,
);
const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce(
(sum, assignment) => (sum, assignment) =>
sum + calculateEffectiveBookedHours({ sum + calculateEffectiveBookedHours({
@@ -245,29 +205,6 @@ async function queryBestProjectResource(
}), }),
0, 0,
); );
let excludedCapacityDays = 0;
for (const fraction of context?.absenceFractionsByDate.values() ?? []) {
excludedCapacityDays += fraction;
}
const holidayWorkdayCount = [...(context?.holidayDates ?? new Set<string>())].reduce((count, isoDate) => (
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const holidayHoursDeduction = [...(context?.holidayDates ?? new Set<string>())].reduce((sum, isoDate) => (
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const remainingHours = Math.max(0, availableHours - bookedHours);
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays);
return { return {
id: resource.id, id: resource.id,
@@ -281,33 +218,27 @@ async function queryBestProjectResource(
metroCity: resource.metroCity?.name ?? null, metroCity: resource.metroCity?.name ?? null,
lcrCents: resource.lcrCents ?? null, lcrCents: resource.lcrCents ?? null,
lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
baseWorkingDays: round1(baseWorkingDays), baseWorkingDays: capacity.baseWorkingDays,
workingDays, workingDays: capacity.workingDays,
excludedCapacityDays: round1(excludedCapacityDays), excludedCapacityDays: capacity.excludedCapacityDays,
baseAvailableHours: round1(baseAvailableHours), baseAvailableHours: capacity.baseAvailableHours,
availableHours: round1(availableHours), availableHours: capacity.availableHours,
bookedHours: round1(bookedHours), bookedHours: capacity.bookedHours,
remainingHours: round1(remainingHours), remainingHours: capacity.remainingHours,
remainingHoursPerDay, remainingHoursPerDay: capacity.remainingHoursPerDay,
projectHours: round1(projectHours), projectHours: round1(projectHours),
assignmentCount: assignments.length, assignmentCount: assignments.length,
holidaySummary: { holidaySummary: {
count: context?.holidayDates.size ?? 0, count: capacity.holidaySummary.count,
workdayCount: holidayWorkdayCount, workdayCount: capacity.holidaySummary.workdayCount,
hoursDeduction: round1(holidayHoursDeduction), hoursDeduction: capacity.holidaySummary.hoursDeduction,
holidayDates: [...(context?.holidayDates ?? new Set<string>())].sort(), holidayDates: capacity.holidaySummary.holidayDates,
}, },
absenceSummary: { absenceSummary: {
dayEquivalent: round1(absenceDayEquivalent), dayEquivalent: capacity.absenceSummary.dayEquivalent,
hoursDeduction: round1(absenceHoursDeduction), hoursDeduction: capacity.absenceSummary.hoursDeduction,
},
capacityBreakdown: {
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
baseAvailableHours: round1(baseAvailableHours),
holidayHoursDeduction: round1(holidayHoursDeduction),
absenceHoursDeduction: round1(absenceHoursDeduction),
availableHours: round1(availableHours),
}, },
capacityBreakdown: capacity.capacityBreakdown,
}; };
}).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay); }).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay);
@@ -0,0 +1,131 @@
import { type WeekdayAvailability } from "@capakraken/shared";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
type ResourceDailyAvailabilityContext,
} from "../lib/resource-capacity.js";
import { averagePerWorkingDay, getBaseDayAvailability, round1 } from "./staffing-shared.js";
type CapacityBooking = {
startDate: Date;
endDate: Date;
hoursPerDay: number;
};
export function summarizeHolidayAndAbsenceCapacity(input: {
availability: WeekdayAvailability;
context: ResourceDailyAvailabilityContext | undefined;
}) {
const holidayDates = [...(input.context?.holidayDates ?? new Set<string>())].sort();
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getBaseDayAvailability(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getBaseDayAvailability(input.availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let excludedCapacityDays = 0;
for (const fraction of input.context?.absenceFractionsByDate.values() ?? []) {
excludedCapacityDays += fraction;
}
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of input.context?.vacationFractionsByDate ?? []) {
const dayHours = getBaseDayAvailability(input.availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || input.context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
return {
excludedCapacityDays: round1(excludedCapacityDays),
holidayDates,
holidayWorkdayCount,
holidayHoursDeduction: round1(holidayHoursDeduction),
absenceDayEquivalent: round1(absenceDayEquivalent),
absenceHoursDeduction: round1(absenceHoursDeduction),
};
}
export function buildResourceCapacitySummary(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
bookings?: CapacityBooking[] | undefined;
}) {
const baseWorkingDays = countEffectiveWorkingDays({
availability: input.availability,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: undefined,
});
const workingDays = countEffectiveWorkingDays({
availability: input.availability,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: input.context,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: input.availability,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: undefined,
});
const availableHours = calculateEffectiveAvailableHours({
availability: input.availability,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: input.context,
});
const bookedHours = (input.bookings ?? []).reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability: input.availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: input.context,
}),
0,
);
const remainingHours = Math.max(0, availableHours - bookedHours);
const holidayAndAbsence = summarizeHolidayAndAbsenceCapacity({
availability: input.availability,
context: input.context,
});
return {
baseWorkingDays: round1(baseWorkingDays),
workingDays: round1(workingDays),
baseAvailableHours: round1(baseAvailableHours),
availableHours: round1(availableHours),
bookedHours: round1(bookedHours),
remainingHours: round1(remainingHours),
remainingHoursPerDay: averagePerWorkingDay(remainingHours, workingDays),
holidaySummary: {
count: holidayAndAbsence.holidayDates.length,
workdayCount: holidayAndAbsence.holidayWorkdayCount,
hoursDeduction: holidayAndAbsence.holidayHoursDeduction,
holidayDates: holidayAndAbsence.holidayDates,
},
absenceSummary: {
dayEquivalent: holidayAndAbsence.absenceDayEquivalent,
hoursDeduction: holidayAndAbsence.absenceHoursDeduction,
},
excludedCapacityDays: holidayAndAbsence.excludedCapacityDays,
capacityBreakdown: {
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
baseAvailableHours: round1(baseAvailableHours),
holidayHoursDeduction: holidayAndAbsence.holidayHoursDeduction,
absenceHoursDeduction: holidayAndAbsence.absenceHoursDeduction,
availableHours: round1(availableHours),
},
};
}
@@ -3,17 +3,11 @@ import { listAssignmentBookings } from "@capakraken/application";
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared"; import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js"; import { fmtEur } from "../lib/format-utils.js";
import { planningReadProcedure, requirePermission } from "../trpc.js"; import { planningReadProcedure, requirePermission } from "../trpc.js";
import { import {
ACTIVE_STATUSES, ACTIVE_STATUSES,
averagePerWorkingDay,
calculateAllocatedHoursForDay, calculateAllocatedHoursForDay,
createLocationLabel, createLocationLabel,
getBaseDayAvailability, getBaseDayAvailability,
@@ -21,6 +15,7 @@ import {
round1, round1,
toIsoDate, toIsoDate,
} from "./staffing-shared.js"; } from "./staffing-shared.js";
import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js";
function fmtDate(value: Date | null | undefined): string | null { function fmtDate(value: Date | null | undefined): string | null {
return value ? value.toISOString().slice(0, 10) : null; return value ? value.toISOString().slice(0, 10) : null;
@@ -143,60 +138,13 @@ async function queryStaffingSuggestions(
const context = contexts.get(resource.id); const context = contexts.get(resource.id);
const resourceBookings = bookingsByResourceId.get(resource.id) ?? []; const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status)); const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
const baseAvailableHours = calculateEffectiveAvailableHours({ const capacity = buildResourceCapacitySummary({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const totalAvailableHours = calculateEffectiveAvailableHours({
availability, availability,
periodStart: startDate, periodStart: startDate,
periodEnd: endDate, periodEnd: endDate,
context, context,
bookings: activeBookings,
}); });
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
});
const allocatedHours = activeBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: startDate,
periodEnd: endDate,
context,
}),
0,
);
const holidayDates = [...(context?.holidayDates ?? new Set<string>())].sort();
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const conflictDays: string[] = []; const conflictDays: string[] = [];
const conflictDetails: Array<{ const conflictDetails: Array<{
date: string; date: string;
@@ -247,11 +195,12 @@ async function queryStaffingSuggestions(
cursor.setUTCDate(cursor.getUTCDate() + 1); cursor.setUTCDate(cursor.getUTCDate() + 1);
} }
const remainingHours = Math.max(0, totalAvailableHours - allocatedHours); const allocatedHours = capacity.bookedHours;
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays); const remainingHours = capacity.remainingHours;
const remainingHoursPerDay = capacity.remainingHoursPerDay;
const utilizationPercent = const utilizationPercent =
totalAvailableHours > 0 capacity.availableHours > 0
? Math.min(100, (allocatedHours / totalAvailableHours) * 100) ? Math.min(100, (allocatedHours / capacity.availableHours) * 100)
: 0; : 0;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
let skills = resource.skills as unknown as SkillRow[]; let skills = resource.skills as unknown as SkillRow[];
@@ -293,19 +242,19 @@ async function queryStaffingSuggestions(
}, },
capacity: { capacity: {
requestedHoursPerDay: round1(hoursPerDay), requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay), requestedHoursTotal: round1(capacity.workingDays * hoursPerDay),
baseWorkingDays: round1(baseWorkingDays), baseWorkingDays: capacity.baseWorkingDays,
effectiveWorkingDays: round1(effectiveWorkingDays), effectiveWorkingDays: capacity.workingDays,
baseAvailableHours: round1(baseAvailableHours), baseAvailableHours: capacity.baseAvailableHours,
effectiveAvailableHours: round1(totalAvailableHours), effectiveAvailableHours: capacity.availableHours,
bookedHours: round1(allocatedHours), bookedHours: capacity.bookedHours,
remainingHours: round1(remainingHours), remainingHours: capacity.remainingHours,
remainingHoursPerDay, remainingHoursPerDay,
holidayCount: holidayDates.length, holidayCount: capacity.holidaySummary.count,
holidayWorkdayCount, holidayWorkdayCount: capacity.holidaySummary.workdayCount,
holidayHoursDeduction: round1(holidayHoursDeduction), holidayHoursDeduction: capacity.holidaySummary.hoursDeduction,
absenceDayEquivalent: round1(absenceDayEquivalent), absenceDayEquivalent: capacity.absenceSummary.dayEquivalent,
absenceHoursDeduction: round1(absenceHoursDeduction), absenceHoursDeduction: capacity.absenceSummary.hoursDeduction,
}, },
conflicts: { conflicts: {
count: conflictDays.length, count: conflictDays.length,