From 741952e1e1477e748d15708c49ed8c0b3b6c03ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:48:01 +0200 Subject: [PATCH] refactor(api): extract staffing capacity read procedures --- .../api/src/router/staffing-capacity-read.ts | 385 ++++++++++++++++++ packages/api/src/router/staffing.ts | 373 +---------------- 2 files changed, 387 insertions(+), 371 deletions(-) create mode 100644 packages/api/src/router/staffing-capacity-read.ts diff --git a/packages/api/src/router/staffing-capacity-read.ts b/packages/api/src/router/staffing-capacity-read.ts new file mode 100644 index 0000000..daafe10 --- /dev/null +++ b/packages/api/src/router/staffing-capacity-read.ts @@ -0,0 +1,385 @@ +import { listAssignmentBookings } from "@capakraken/application"; +import { 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 { planningReadProcedure } from "../trpc.js"; +import { + ACTIVE_STATUSES, + averagePerWorkingDay, + calculateAllocatedHoursForDay, + getEffectiveDayAvailability, + round1, + toIsoDate, +} from "./staffing-shared.js"; + +export const staffingCapacityReadProcedures = { + searchCapacity: planningReadProcedure + .input( + z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + minHoursPerDay: z.number().optional().default(4), + roleName: z.string().optional(), + chapter: z.string().optional(), + limit: z.number().int().min(1).max(100).optional().default(20), + }), + ) + .query(async ({ ctx, input }) => { + const where: Record = { isActive: true }; + if (input.roleName) { + where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } }; + } + if (input.chapter) { + where.chapter = { contains: input.chapter, mode: "insensitive" }; + } + + const resources = await ctx.db.resource.findMany({ + where, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + areaRole: { select: { name: true } }, + chapter: true, + }, + take: 100, + }); + + const bookings = await listAssignmentBookings(ctx.db, { + startDate: input.startDate, + endDate: input.endDate, + resourceIds: resources.map((resource) => resource.id), + }); + + const bookingsByResourceId = new Map(); + for (const booking of bookings) { + if (!booking.resourceId) { + continue; + } + const existing = bookingsByResourceId.get(booking.resourceId) ?? []; + existing.push(booking); + bookingsByResourceId.set(booking.resourceId, existing); + } + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + resources.map((resource) => ({ + id: resource.id, + availability: resource.availability as unknown as WeekdayAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + })), + input.startDate, + input.endDate, + ); + + const results = resources + .map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const workingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce( + (sum, booking) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }), + 0, + ); + const remainingHours = Math.max(0, availableHours - bookedHours); + + return { + id: resource.id, + name: resource.displayName, + eid: resource.eid, + role: resource.areaRole?.name ?? null, + chapter: resource.chapter, + workingDays, + availableHours: round1(remainingHours), + availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays), + }; + }) + .filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay) + .sort((left, right) => right.availableHours - left.availableHours) + .slice(0, input.limit); + + return { + period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`, + minHoursFilter: input.minHoursPerDay, + results, + totalFound: results.length, + }; + }), + + analyzeUtilization: planningReadProcedure + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), + ) + .query(async ({ ctx, input }) => { + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + chargeabilityTarget: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }), + "Resource", + ); + + const resourceBookings = await listAssignmentBookings(ctx.db, { + startDate: input.startDate, + endDate: input.endDate, + resourceIds: [resource.id], + }); + + const availability = resource.availability as unknown as WeekdayAvailability; + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + input.startDate, + input.endDate, + ); + const context = contexts.get(resource.id); + const activeBookings = resourceBookings.map((booking) => ({ + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + status: booking.status, + projectName: booking.project.name, + isChargeable: booking.project.orderType === "CHARGEABLE", + })); + + const overallocatedDays: string[] = []; + const underutilizedDays: string[] = []; + let totalAvailableHours = 0; + let totalChargeableHours = 0; + const cursor = new Date(input.startDate); + cursor.setUTCHours(0, 0, 0, 0); + const end = new Date(input.endDate); + end.setUTCHours(0, 0, 0, 0); + + while (cursor <= end) { + const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); + if (availableHoursForDay > 0) { + const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({ + bookings: activeBookings, + date: cursor, + context, + }); + totalAvailableHours += availableHoursForDay; + totalChargeableHours += chargeableHours; + + if (allocatedHours > availableHoursForDay) { + overallocatedDays.push(toIsoDate(cursor)); + } else if (allocatedHours < availableHoursForDay * 0.5) { + underutilizedDays.push(toIsoDate(cursor)); + } + } + + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + const currentChargeability = totalAvailableHours > 0 + ? (totalChargeableHours / totalAvailableHours) * 100 + : 0; + + return { + resourceId: resource.id, + resourceName: resource.displayName, + chargeabilityTarget: resource.chargeabilityTarget, + currentChargeability, + chargeabilityGap: resource.chargeabilityTarget - currentChargeability, + allocations: activeBookings + .filter((booking) => ACTIVE_STATUSES.has(booking.status)) + .map((booking) => ({ + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + projectName: booking.projectName, + isChargeable: booking.isChargeable, + })), + overallocatedDays, + underutilizedDays, + }; + }), + + findCapacity: planningReadProcedure + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + minAvailableHoursPerDay: z.number().optional().default(4), + }), + ) + .query(async ({ ctx, input }) => { + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }), + "Resource", + ); + + const resourceBookings = await listAssignmentBookings(ctx.db, { + startDate: input.startDate, + endDate: input.endDate, + resourceIds: [resource.id], + }); + + const availability = resource.availability as unknown as WeekdayAvailability; + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + input.startDate, + input.endDate, + ); + const context = contexts.get(resource.id); + const windows: Array<{ + resourceId: string; + resourceName: string; + startDate: Date; + endDate: Date; + availableHoursPerDay: number; + availableDays: number; + totalAvailableHours: number; + }> = []; + + let windowStart: Date | null = null; + let windowAvailableDays = 0; + let windowTotalHours = 0; + let windowMinHours = Number.POSITIVE_INFINITY; + + const closeWindow = (closeDate: Date) => { + if (windowStart && windowAvailableDays > 0) { + const previousDay = new Date(closeDate); + previousDay.setUTCDate(previousDay.getUTCDate() - 1); + windows.push({ + resourceId: resource.id, + resourceName: resource.displayName, + startDate: new Date(windowStart), + endDate: previousDay, + availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0, + availableDays: windowAvailableDays, + totalAvailableHours: Math.round(windowTotalHours * 10) / 10, + }); + } + windowStart = null; + windowAvailableDays = 0; + windowTotalHours = 0; + windowMinHours = Number.POSITIVE_INFINITY; + }; + + const cursor = new Date(input.startDate); + cursor.setUTCHours(0, 0, 0, 0); + const end = new Date(input.endDate); + end.setUTCHours(0, 0, 0, 0); + + while (cursor <= end) { + const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); + if (availableHoursForDay <= 0) { + closeWindow(cursor); + cursor.setUTCDate(cursor.getUTCDate() + 1); + continue; + } + + const { allocatedHours } = calculateAllocatedHoursForDay({ + bookings: resourceBookings.map((booking) => ({ + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + status: booking.status, + })), + date: cursor, + context, + }); + const freeHours = Math.max(0, availableHoursForDay - allocatedHours); + + if (freeHours >= input.minAvailableHoursPerDay) { + if (!windowStart) { + windowStart = new Date(cursor); + } + windowAvailableDays += 1; + windowTotalHours += freeHours; + windowMinHours = Math.min(windowMinHours, freeHours); + } else { + closeWindow(cursor); + } + + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + closeWindow(new Date(end.getTime() + 86_400_000)); + + return windows; + }), +}; diff --git a/packages/api/src/router/staffing.ts b/packages/api/src/router/staffing.ts index 6fb3d30..8e77deb 100644 --- a/packages/api/src/router/staffing.ts +++ b/packages/api/src/router/staffing.ts @@ -12,6 +12,7 @@ import { import { fmtEur } from "../lib/format-utils.js"; import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js"; import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js"; +import { staffingCapacityReadProcedures } from "./staffing-capacity-read.js"; import { ACTIVE_STATUSES, averagePerWorkingDay, @@ -497,376 +498,6 @@ export const staffingRouter = createTRPCRouter({ .slice(0, input.limit), }; }), - - searchCapacity: planningReadProcedure - .input( - z.object({ - startDate: z.coerce.date(), - endDate: z.coerce.date(), - minHoursPerDay: z.number().optional().default(4), - roleName: z.string().optional(), - chapter: z.string().optional(), - limit: z.number().int().min(1).max(100).optional().default(20), - }), - ) - .query(async ({ ctx, input }) => { - const where: Record = { isActive: true }; - if (input.roleName) { - where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } }; - } - if (input.chapter) { - where.chapter = { contains: input.chapter, mode: "insensitive" }; - } - - const resources = await ctx.db.resource.findMany({ - where, - select: { - id: true, - displayName: true, - eid: true, - fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - areaRole: { select: { name: true } }, - chapter: true, - }, - take: 100, - }); - - const bookings = await listAssignmentBookings(ctx.db, { - startDate: input.startDate, - endDate: input.endDate, - resourceIds: resources.map((resource) => resource.id), - }); - - const bookingsByResourceId = new Map(); - for (const booking of bookings) { - if (!booking.resourceId) { - continue; - } - const existing = bookingsByResourceId.get(booking.resourceId) ?? []; - existing.push(booking); - bookingsByResourceId.set(booking.resourceId, existing); - } - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - resources.map((resource) => ({ - id: resource.id, - availability: resource.availability as unknown as WeekdayAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - })), - input.startDate, - input.endDate, - ); - - const results = resources - .map((resource) => { - const availability = resource.availability as unknown as WeekdayAvailability; - const context = contexts.get(resource.id); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - const availableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce( - (sum, booking) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }), - 0, - ); - const remainingHours = Math.max(0, availableHours - bookedHours); - - return { - id: resource.id, - name: resource.displayName, - eid: resource.eid, - role: resource.areaRole?.name ?? null, - chapter: resource.chapter, - workingDays, - availableHours: round1(remainingHours), - availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays), - }; - }) - .filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay) - .sort((left, right) => right.availableHours - left.availableHours) - .slice(0, input.limit); - - return { - period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`, - minHoursFilter: input.minHoursPerDay, - results, - totalFound: results.length, - }; - }), - - /** - * Analyze utilization for a specific resource over a date range. - */ - analyzeUtilization: planningReadProcedure - .input( - z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - }), - ) - .query(async ({ ctx, input }) => { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - chargeabilityTarget: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }), - "Resource", - ); - - const resourceBookings = await listAssignmentBookings(ctx.db, { - startDate: input.startDate, - endDate: input.endDate, - resourceIds: [resource.id], - }); - - const availability = resource.availability as unknown as WeekdayAvailability; - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - input.startDate, - input.endDate, - ); - const context = contexts.get(resource.id); - const activeBookings = resourceBookings.map((booking) => ({ - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - status: booking.status, - projectName: booking.project.name, - isChargeable: booking.project.orderType === "CHARGEABLE", - })); - - const overallocatedDays: string[] = []; - const underutilizedDays: string[] = []; - let totalAvailableHours = 0; - let totalChargeableHours = 0; - const cursor = new Date(input.startDate); - cursor.setUTCHours(0, 0, 0, 0); - const end = new Date(input.endDate); - end.setUTCHours(0, 0, 0, 0); - - while (cursor <= end) { - const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); - if (availableHoursForDay > 0) { - const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({ - bookings: activeBookings, - date: cursor, - context, - }); - totalAvailableHours += availableHoursForDay; - totalChargeableHours += chargeableHours; - - if (allocatedHours > availableHoursForDay) { - overallocatedDays.push(toIsoDate(cursor)); - } else if (allocatedHours < availableHoursForDay * 0.5) { - underutilizedDays.push(toIsoDate(cursor)); - } - } - - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - - const currentChargeability = totalAvailableHours > 0 - ? (totalChargeableHours / totalAvailableHours) * 100 - : 0; - - return { - resourceId: resource.id, - resourceName: resource.displayName, - chargeabilityTarget: resource.chargeabilityTarget, - currentChargeability, - chargeabilityGap: resource.chargeabilityTarget - currentChargeability, - allocations: activeBookings - .filter((booking) => ACTIVE_STATUSES.has(booking.status)) - .map((booking) => ({ - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - projectName: booking.projectName, - isChargeable: booking.isChargeable, - })), - overallocatedDays, - underutilizedDays, - }; - }), - - /** - * Find capacity windows for a resource. - */ - findCapacity: planningReadProcedure - .input( - z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - minAvailableHoursPerDay: z.number().optional().default(4), - }), - ) - .query(async ({ ctx, input }) => { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }), - "Resource", - ); - - const resourceBookings = await listAssignmentBookings(ctx.db, { - startDate: input.startDate, - endDate: input.endDate, - resourceIds: [resource.id], - }); - - const availability = resource.availability as unknown as WeekdayAvailability; - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - input.startDate, - input.endDate, - ); - const context = contexts.get(resource.id); - const windows: Array<{ - resourceId: string; - resourceName: string; - startDate: Date; - endDate: Date; - availableHoursPerDay: number; - availableDays: number; - totalAvailableHours: number; - }> = []; - - let windowStart: Date | null = null; - let windowAvailableDays = 0; - let windowTotalHours = 0; - let windowMinHours = Number.POSITIVE_INFINITY; - - const closeWindow = (closeDate: Date) => { - if (windowStart && windowAvailableDays > 0) { - const previousDay = new Date(closeDate); - previousDay.setUTCDate(previousDay.getUTCDate() - 1); - windows.push({ - resourceId: resource.id, - resourceName: resource.displayName, - startDate: new Date(windowStart), - endDate: previousDay, - availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0, - availableDays: windowAvailableDays, - totalAvailableHours: Math.round(windowTotalHours * 10) / 10, - }); - } - windowStart = null; - windowAvailableDays = 0; - windowTotalHours = 0; - windowMinHours = Number.POSITIVE_INFINITY; - }; - - const cursor = new Date(input.startDate); - cursor.setUTCHours(0, 0, 0, 0); - const end = new Date(input.endDate); - end.setUTCHours(0, 0, 0, 0); - - while (cursor <= end) { - const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); - if (availableHoursForDay <= 0) { - closeWindow(cursor); - cursor.setUTCDate(cursor.getUTCDate() + 1); - continue; - } - - const { allocatedHours } = calculateAllocatedHoursForDay({ - bookings: resourceBookings.map((booking) => ({ - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - status: booking.status, - })), - date: cursor, - context, - }); - const freeHours = Math.max(0, availableHoursForDay - allocatedHours); - - if (freeHours >= input.minAvailableHoursPerDay) { - if (!windowStart) { - windowStart = new Date(cursor); - } - windowAvailableDays += 1; - windowTotalHours += freeHours; - windowMinHours = Math.min(windowMinHours, freeHours); - } else { - closeWindow(cursor); - } - - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - - closeWindow(new Date(end.getTime() + 86_400_000)); - - return windows; - }), - + ...staffingCapacityReadProcedures, ...staffingBestProjectResourceProcedures, });