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 { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
import { planningReadProcedure, requirePermission } from "../trpc.js";
import {
averagePerWorkingDay,
createDateRange,
getBaseDayAvailability,
round1,
toIsoDate,
} from "./staffing-shared.js";
import { createDateRange, round1, toIsoDate } from "./staffing-shared.js";
import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js";
type BestProjectResourceRankingMode =
| "lowest_lcr"
@@ -194,44 +184,14 @@ async function queryBestProjectResource(
const candidates = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
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 bookedHours = assignments.reduce(
(sum, assignment) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
}),
0,
);
const capacity = buildResourceCapacitySummary({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
bookings: assignments,
});
const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce(
(sum, assignment) =>
sum + calculateEffectiveBookedHours({
@@ -245,29 +205,6 @@ async function queryBestProjectResource(
}),
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 {
id: resource.id,
@@ -281,33 +218,27 @@ async function queryBestProjectResource(
metroCity: resource.metroCity?.name ?? null,
lcrCents: resource.lcrCents ?? null,
lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
baseWorkingDays: round1(baseWorkingDays),
workingDays,
excludedCapacityDays: round1(excludedCapacityDays),
baseAvailableHours: round1(baseAvailableHours),
availableHours: round1(availableHours),
bookedHours: round1(bookedHours),
remainingHours: round1(remainingHours),
remainingHoursPerDay,
baseWorkingDays: capacity.baseWorkingDays,
workingDays: capacity.workingDays,
excludedCapacityDays: capacity.excludedCapacityDays,
baseAvailableHours: capacity.baseAvailableHours,
availableHours: capacity.availableHours,
bookedHours: capacity.bookedHours,
remainingHours: capacity.remainingHours,
remainingHoursPerDay: capacity.remainingHoursPerDay,
projectHours: round1(projectHours),
assignmentCount: assignments.length,
holidaySummary: {
count: context?.holidayDates.size ?? 0,
workdayCount: holidayWorkdayCount,
hoursDeduction: round1(holidayHoursDeduction),
holidayDates: [...(context?.holidayDates ?? new Set<string>())].sort(),
count: capacity.holidaySummary.count,
workdayCount: capacity.holidaySummary.workdayCount,
hoursDeduction: capacity.holidaySummary.hoursDeduction,
holidayDates: capacity.holidaySummary.holidayDates,
},
absenceSummary: {
dayEquivalent: round1(absenceDayEquivalent),
hoursDeduction: round1(absenceHoursDeduction),
},
capacityBreakdown: {
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
baseAvailableHours: round1(baseAvailableHours),
holidayHoursDeduction: round1(holidayHoursDeduction),
absenceHoursDeduction: round1(absenceHoursDeduction),
availableHours: round1(availableHours),
dayEquivalent: capacity.absenceSummary.dayEquivalent,
hoursDeduction: capacity.absenceSummary.hoursDeduction,
},
capacityBreakdown: capacity.capacityBreakdown,
};
}).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay);