diff --git a/packages/api/src/router/staffing-best-project-resource.ts b/packages/api/src/router/staffing-best-project-resource.ts index bcaae6e..956336b 100644 --- a/packages/api/src/router/staffing-best-project-resource.ts +++ b/packages/api/src/router/staffing-best-project-resource.ts @@ -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())].reduce((count, isoDate) => ( - count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) - ), 0); - const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].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())].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); diff --git a/packages/api/src/router/staffing-capacity-summary.ts b/packages/api/src/router/staffing-capacity-summary.ts new file mode 100644 index 0000000..e3d78df --- /dev/null +++ b/packages/api/src/router/staffing-capacity-summary.ts @@ -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())].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), + }, + }; +} diff --git a/packages/api/src/router/staffing-suggestions-read.ts b/packages/api/src/router/staffing-suggestions-read.ts index 4c0401f..b86443d 100644 --- a/packages/api/src/router/staffing-suggestions-read.ts +++ b/packages/api/src/router/staffing-suggestions-read.ts @@ -3,17 +3,11 @@ import { listAssignmentBookings } from "@capakraken/application"; 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 { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; import { planningReadProcedure, requirePermission } from "../trpc.js"; import { ACTIVE_STATUSES, - averagePerWorkingDay, calculateAllocatedHoursForDay, createLocationLabel, getBaseDayAvailability, @@ -21,6 +15,7 @@ import { round1, toIsoDate, } from "./staffing-shared.js"; +import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js"; function fmtDate(value: Date | null | undefined): string | null { return value ? value.toISOString().slice(0, 10) : null; @@ -143,60 +138,13 @@ async function queryStaffingSuggestions( const context = contexts.get(resource.id); const resourceBookings = bookingsByResourceId.get(resource.id) ?? []; const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status)); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: startDate, - periodEnd: endDate, - context: undefined, - }); - const totalAvailableHours = calculateEffectiveAvailableHours({ + const capacity = buildResourceCapacitySummary({ availability, periodStart: startDate, periodEnd: endDate, 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())].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 conflictDetails: Array<{ date: string; @@ -247,11 +195,12 @@ async function queryStaffingSuggestions( cursor.setUTCDate(cursor.getUTCDate() + 1); } - const remainingHours = Math.max(0, totalAvailableHours - allocatedHours); - const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays); + const allocatedHours = capacity.bookedHours; + const remainingHours = capacity.remainingHours; + const remainingHoursPerDay = capacity.remainingHoursPerDay; const utilizationPercent = - totalAvailableHours > 0 - ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) + capacity.availableHours > 0 + ? Math.min(100, (allocatedHours / capacity.availableHours) * 100) : 0; type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; let skills = resource.skills as unknown as SkillRow[]; @@ -293,19 +242,19 @@ async function queryStaffingSuggestions( }, capacity: { requestedHoursPerDay: round1(hoursPerDay), - requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay), - baseWorkingDays: round1(baseWorkingDays), - effectiveWorkingDays: round1(effectiveWorkingDays), - baseAvailableHours: round1(baseAvailableHours), - effectiveAvailableHours: round1(totalAvailableHours), - bookedHours: round1(allocatedHours), - remainingHours: round1(remainingHours), + requestedHoursTotal: round1(capacity.workingDays * hoursPerDay), + baseWorkingDays: capacity.baseWorkingDays, + effectiveWorkingDays: capacity.workingDays, + baseAvailableHours: capacity.baseAvailableHours, + effectiveAvailableHours: capacity.availableHours, + bookedHours: capacity.bookedHours, + remainingHours: capacity.remainingHours, remainingHoursPerDay, - holidayCount: holidayDates.length, - holidayWorkdayCount, - holidayHoursDeduction: round1(holidayHoursDeduction), - absenceDayEquivalent: round1(absenceDayEquivalent), - absenceHoursDeduction: round1(absenceHoursDeduction), + holidayCount: capacity.holidaySummary.count, + holidayWorkdayCount: capacity.holidaySummary.workdayCount, + holidayHoursDeduction: capacity.holidaySummary.hoursDeduction, + absenceDayEquivalent: capacity.absenceSummary.dayEquivalent, + absenceHoursDeduction: capacity.absenceSummary.hoursDeduction, }, conflicts: { count: conflictDays.length,