From cc9cc22c9b668b82c61bf324fb659df8316d0464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:45:20 +0200 Subject: [PATCH] refactor(api): extract staffing best-project procedures --- .../router/staffing-best-project-resource.ts | 416 ++++++++++++++ packages/api/src/router/staffing-shared.ts | 123 ++++ packages/api/src/router/staffing.ts | 534 +----------------- 3 files changed, 551 insertions(+), 522 deletions(-) create mode 100644 packages/api/src/router/staffing-best-project-resource.ts create mode 100644 packages/api/src/router/staffing-shared.ts diff --git a/packages/api/src/router/staffing-best-project-resource.ts b/packages/api/src/router/staffing-best-project-resource.ts new file mode 100644 index 0000000..bcaae6e --- /dev/null +++ b/packages/api/src/router/staffing-best-project-resource.ts @@ -0,0 +1,416 @@ +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 { fmtEur } from "../lib/format-utils.js"; +import { planningReadProcedure, requirePermission } from "../trpc.js"; +import { + averagePerWorkingDay, + createDateRange, + getBaseDayAvailability, + round1, + toIsoDate, +} from "./staffing-shared.js"; + +type BestProjectResourceRankingMode = + | "lowest_lcr" + | "highest_remaining_hours_per_day" + | "highest_remaining_hours"; + +type BestProjectResourceInput = { + projectId: string; + startDate: Date; + endDate: Date; + minHoursPerDay: number; + rankingMode: BestProjectResourceRankingMode; + chapter?: string | undefined; + roleName?: string | undefined; +}; + +type BestProjectResourceDbClient = + Parameters[0] + & { + assignment: { + findMany: (args: Record) => Promise; + }; + }; + +type BestProjectResourceAssignmentRecord = { + resourceId: string; + hoursPerDay: number; + startDate: Date; + endDate: Date; + status: string; + resource: { + id: string; + eid: string; + displayName: string; + chapter: string | null; + lcrCents: number | null; + availability: unknown; + countryId: string | null; + federalState: string | null; + metroCityId: string | null; + country: { code: string; name: string } | null; + metroCity: { name: string } | null; + areaRole: { name: string } | null; + }; +}; + +type BestProjectResourceOverlapAssignmentRecord = { + resourceId: string; + projectId: string; + hoursPerDay: number; + startDate: Date; + endDate: Date; + status: string; + project: { name: string; shortCode: string | null } | null; +}; + +async function queryBestProjectResource( + db: BestProjectResourceDbClient, + input: BestProjectResourceInput, +) { + const projectAssignmentsResult = await db.assignment.findMany({ + where: { + projectId: input.projectId, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + resource: { + isActive: true, + ...(input.chapter ? { chapter: { contains: input.chapter, mode: "insensitive" } } : {}), + ...(input.roleName ? { areaRole: { name: { contains: input.roleName, mode: "insensitive" } } } : {}), + }, + }, + select: { + resourceId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + resource: { + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + lcrCents: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + areaRole: { select: { name: true } }, + }, + }, + }, + orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], + }); + const projectAssignments = (Array.isArray(projectAssignmentsResult) + ? projectAssignmentsResult + : []) as BestProjectResourceAssignmentRecord[]; + + if (projectAssignments.length === 0) { + return { + period: { + startDate: toIsoDate(input.startDate), + endDate: toIsoDate(input.endDate), + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + }, + filters: { + chapter: input.chapter ?? null, + roleName: input.roleName ?? null, + }, + candidateCount: 0, + candidates: [], + bestMatch: null, + note: "No active project resources matched the requested filters in the selected period.", + }; + } + + const resourcesById = new Map(); + const assignmentsOnProjectByResourceId = new Map(); + for (const assignment of projectAssignments) { + resourcesById.set(assignment.resourceId, assignment.resource); + const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? []; + items.push(assignment); + assignmentsOnProjectByResourceId.set(assignment.resourceId, items); + } + + const resourceIds = [...resourcesById.keys()]; + const overlappingAssignmentsResult = await db.assignment.findMany({ + where: { + resourceId: { in: resourceIds }, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + resourceId: true, + projectId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], + }); + const overlappingAssignments = (Array.isArray(overlappingAssignmentsResult) + ? overlappingAssignmentsResult + : []) as BestProjectResourceOverlapAssignmentRecord[]; + + const assignmentsByResourceId = new Map(); + for (const assignment of overlappingAssignments) { + const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; + items.push(assignment); + assignmentsByResourceId.set(assignment.resourceId, items); + } + + const resources = [...resourcesById.values()]; + const contexts = await loadResourceDailyAvailabilityContexts( + 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 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 projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( + (sum, assignment) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }), + 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, + eid: resource.eid, + name: resource.displayName, + role: resource.areaRole?.name ?? null, + chapter: resource.chapter ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState ?? null, + 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, + projectHours: round1(projectHours), + assignmentCount: assignments.length, + holidaySummary: { + count: context?.holidayDates.size ?? 0, + workdayCount: holidayWorkdayCount, + hoursDeduction: round1(holidayHoursDeduction), + holidayDates: [...(context?.holidayDates ?? new Set())].sort(), + }, + 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), + }, + }; + }).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay); + + candidates.sort((left, right) => { + if (input.rankingMode === "highest_remaining_hours_per_day") { + return right.remainingHoursPerDay - left.remainingHoursPerDay + || right.remainingHours - left.remainingHours + || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); + } + if (input.rankingMode === "highest_remaining_hours") { + return right.remainingHours - left.remainingHours + || right.remainingHoursPerDay - left.remainingHoursPerDay + || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); + } + return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER) + || right.remainingHoursPerDay - left.remainingHoursPerDay + || right.remainingHours - left.remainingHours; + }); + + return { + period: { + startDate: toIsoDate(input.startDate), + endDate: toIsoDate(input.endDate), + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + }, + filters: { + chapter: input.chapter ?? null, + roleName: input.roleName ?? null, + }, + candidateCount: candidates.length, + bestMatch: candidates[0] ?? null, + candidates, + }; +} + +export const staffingBestProjectResourceProcedures = { + findBestProjectResource: planningReadProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + minHoursPerDay: z.number().min(0).default(3), + rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), + chapter: z.string().optional(), + roleName: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input); + }), + + getBestProjectResourceDetail: planningReadProcedure + .input( + z.object({ + projectId: z.string().min(1), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + durationDays: z.number().int().min(1).optional(), + minHoursPerDay: z.number().min(0).default(3), + rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), + chapter: z.string().optional(), + roleName: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + const project = await findUniqueOrThrow(ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + status: true, + responsiblePerson: true, + }, + }), "Project"); + const { startDate, endDate } = createDateRange({ + startDate: input.startDate, + endDate: input.endDate, + durationDays: input.durationDays, + }); + const result = await queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, { + projectId: input.projectId, + startDate, + endDate, + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + ...(input.chapter ? { chapter: input.chapter } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + + return { + ...result, + project: { + id: project.id, + name: project.name, + shortCode: project.shortCode, + status: project.status, + responsiblePerson: project.responsiblePerson, + }, + }; + }), +}; diff --git a/packages/api/src/router/staffing-shared.ts b/packages/api/src/router/staffing-shared.ts new file mode 100644 index 0000000..fcca544 --- /dev/null +++ b/packages/api/src/router/staffing-shared.ts @@ -0,0 +1,123 @@ +import { type WeekdayAvailability } from "@capakraken/shared"; +import { type ResourceDailyAvailabilityContext } from "../lib/resource-capacity.js"; + +const DAY_KEYS: (keyof WeekdayAvailability)[] = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]; + +export const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]); + +export function toIsoDate(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function createUtcDate(year: number, monthIndex: number, day: number): Date { + return new Date(Date.UTC(year, monthIndex, day)); +} + +function normalizeUtcDate(value: Date): Date { + return createUtcDate(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()); +} + +export function createDateRange(input: { + startDate?: Date | undefined; + endDate?: Date | undefined; + durationDays?: number | undefined; +}): { startDate: Date; endDate: Date } { + const startDate = input.startDate + ? normalizeUtcDate(input.startDate) + : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); + const endDate = input.endDate + ? normalizeUtcDate(input.endDate) + : createUtcDate( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), + ); + + if (endDate < startDate) { + throw new Error("endDate must be on or after startDate."); + } + + return { startDate, endDate }; +} + +export function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +export function getBaseDayAvailability( + availability: WeekdayAvailability, + date: Date, +): number { + const key = DAY_KEYS[date.getUTCDay()]; + return key ? (availability[key] ?? 0) : 0; +} + +export function getEffectiveDayAvailability( + availability: WeekdayAvailability, + date: Date, + context: ResourceDailyAvailabilityContext | undefined, +): number { + const key = DAY_KEYS[date.getUTCDay()]; + const baseHours = key ? (availability[key] ?? 0) : 0; + if (baseHours <= 0) { + return 0; + } + const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0; + return Math.max(0, baseHours * (1 - fraction)); +} + +function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean { + return date >= startDate && date <= endDate; +} + +export function averagePerWorkingDay(totalHours: number, workingDays: number): number { + if (workingDays <= 0) { + return 0; + } + return round1(totalHours / workingDays); +} + +export function createLocationLabel(input: { + countryCode?: string | null; + federalState?: string | null; + metroCityName?: string | null; +}): string { + return [ + input.countryCode ?? null, + input.federalState ?? null, + input.metroCityName ?? null, + ].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / "); +} + +export function calculateAllocatedHoursForDay(input: { + bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>; + date: Date; + context: ResourceDailyAvailabilityContext | undefined; +}): { allocatedHours: number; chargeableHours: number } { + const isoDate = toIsoDate(input.date); + const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0)); + + return input.bookings.reduce( + (acc, booking) => { + if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) { + return acc; + } + + const effectiveHours = booking.hoursPerDay * dayFraction; + acc.allocatedHours += effectiveHours; + if (booking.isChargeable) { + acc.chargeableHours += effectiveHours; + } + return acc; + }, + { allocatedHours: 0, chargeableHours: 0 }, + ); +} diff --git a/packages/api/src/router/staffing.ts b/packages/api/src/router/staffing.ts index cc8e1a4..6fb3d30 100644 --- a/packages/api/src/router/staffing.ts +++ b/packages/api/src/router/staffing.ts @@ -8,135 +8,25 @@ import { calculateEffectiveBookedHours, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, - type ResourceDailyAvailabilityContext, } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js"; - -const DAY_KEYS: (keyof WeekdayAvailability)[] = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -]; -const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]); - -function toIsoDate(value: Date): string { - return value.toISOString().slice(0, 10); -} +import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js"; +import { + ACTIVE_STATUSES, + averagePerWorkingDay, + calculateAllocatedHoursForDay, + createLocationLabel, + getBaseDayAvailability, + getEffectiveDayAvailability, + round1, + toIsoDate, +} from "./staffing-shared.js"; function fmtDate(value: Date | null | undefined): string | null { return value ? value.toISOString().slice(0, 10) : null; } -function createUtcDate(year: number, monthIndex: number, day: number): Date { - return new Date(Date.UTC(year, monthIndex, day)); -} - -function normalizeUtcDate(value: Date): Date { - return createUtcDate(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()); -} - -function createDateRange(input: { - startDate?: Date | undefined; - endDate?: Date | undefined; - durationDays?: number | undefined; -}): { startDate: Date; endDate: Date } { - const startDate = input.startDate - ? normalizeUtcDate(input.startDate) - : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); - const endDate = input.endDate - ? normalizeUtcDate(input.endDate) - : createUtcDate( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), - ); - - if (endDate < startDate) { - throw new Error("endDate must be on or after startDate."); - } - - return { startDate, endDate }; -} - -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - -function getBaseDayAvailability( - availability: WeekdayAvailability, - date: Date, -): number { - const key = DAY_KEYS[date.getUTCDay()]; - return key ? (availability[key] ?? 0) : 0; -} - -function getEffectiveDayAvailability( - availability: WeekdayAvailability, - date: Date, - context: ResourceDailyAvailabilityContext | undefined, -): number { - const key = DAY_KEYS[date.getUTCDay()]; - const baseHours = key ? (availability[key] ?? 0) : 0; - if (baseHours <= 0) { - return 0; - } - const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0; - return Math.max(0, baseHours * (1 - fraction)); -} - -function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean { - return date >= startDate && date <= endDate; -} - -function averagePerWorkingDay(totalHours: number, workingDays: number): number { - if (workingDays <= 0) { - return 0; - } - return round1(totalHours / workingDays); -} - -function createLocationLabel(input: { - countryCode?: string | null; - federalState?: string | null; - metroCityName?: string | null; -}): string { - return [ - input.countryCode ?? null, - input.federalState ?? null, - input.metroCityName ?? null, - ].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / "); -} - -function calculateAllocatedHoursForDay(input: { - bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>; - date: Date; - context: ResourceDailyAvailabilityContext | undefined; -}): { allocatedHours: number; chargeableHours: number } { - const isoDate = toIsoDate(input.date); - const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0)); - - return input.bookings.reduce( - (acc, booking) => { - if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) { - return acc; - } - - const effectiveHours = booking.hoursPerDay * dayFraction; - acc.allocatedHours += effectiveHours; - if (booking.isChargeable) { - acc.chargeableHours += effectiveHours; - } - return acc; - }, - { allocatedHours: 0, chargeableHours: 0 }, - ); -} - type StaffingSuggestionInput = { requiredSkills: string[]; preferredSkills?: string[] | undefined; @@ -178,67 +68,6 @@ type StaffingResourceRecord = { areaRole: { name: string } | null; }; -type BestProjectResourceRankingMode = - | "lowest_lcr" - | "highest_remaining_hours_per_day" - | "highest_remaining_hours"; - -type BestProjectResourceInput = { - projectId: string; - startDate: Date; - endDate: Date; - minHoursPerDay: number; - rankingMode: BestProjectResourceRankingMode; - chapter?: string | undefined; - roleName?: string | undefined; -}; - -type BestProjectResourceDbClient = - Parameters[0] - & { - assignment: { - findMany: (args: Record) => Promise; - }; - }; - -type BestProjectResourceDetailDbClient = BestProjectResourceDbClient & { - project: { - findUnique: (args: Record) => Promise; - }; -}; - -type BestProjectResourceAssignmentRecord = { - resourceId: string; - hoursPerDay: number; - startDate: Date; - endDate: Date; - status: string; - resource: { - id: string; - eid: string; - displayName: string; - chapter: string | null; - lcrCents: number | null; - availability: unknown; - countryId: string | null; - federalState: string | null; - metroCityId: string | null; - country: { code: string; name: string } | null; - metroCity: { name: string } | null; - areaRole: { name: string } | null; - }; -}; - -type BestProjectResourceOverlapAssignmentRecord = { - resourceId: string; - projectId: string; - hoursPerDay: number; - startDate: Date; - endDate: Date; - status: string; - project: { name: string; shortCode: string | null } | null; -}; - async function queryStaffingSuggestions( db: StaffingSuggestionsDbClient, input: StaffingSuggestionInput, @@ -584,278 +413,6 @@ async function queryStaffingSuggestions( }); } -async function queryBestProjectResource( - db: BestProjectResourceDbClient, - input: BestProjectResourceInput, -) { - const projectAssignmentsResult = await db.assignment.findMany({ - where: { - projectId: input.projectId, - status: { not: "CANCELLED" }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - resource: { - isActive: true, - ...(input.chapter ? { chapter: { contains: input.chapter, mode: "insensitive" } } : {}), - ...(input.roleName ? { areaRole: { name: { contains: input.roleName, mode: "insensitive" } } } : {}), - }, - }, - select: { - resourceId: true, - hoursPerDay: true, - startDate: true, - endDate: true, - status: true, - resource: { - select: { - id: true, - eid: true, - displayName: true, - chapter: true, - lcrCents: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - areaRole: { select: { name: true } }, - }, - }, - }, - orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], - }); - const projectAssignments = (Array.isArray(projectAssignmentsResult) - ? projectAssignmentsResult - : []) as BestProjectResourceAssignmentRecord[]; - - if (projectAssignments.length === 0) { - return { - period: { - startDate: toIsoDate(input.startDate), - endDate: toIsoDate(input.endDate), - minHoursPerDay: input.minHoursPerDay, - rankingMode: input.rankingMode, - }, - filters: { - chapter: input.chapter ?? null, - roleName: input.roleName ?? null, - }, - candidateCount: 0, - candidates: [], - bestMatch: null, - note: "No active project resources matched the requested filters in the selected period.", - }; - } - - const resourcesById = new Map(); - const assignmentsOnProjectByResourceId = new Map(); - for (const assignment of projectAssignments) { - resourcesById.set(assignment.resourceId, assignment.resource); - const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? []; - items.push(assignment); - assignmentsOnProjectByResourceId.set(assignment.resourceId, items); - } - - const resourceIds = [...resourcesById.keys()]; - const overlappingAssignmentsResult = await db.assignment.findMany({ - where: { - resourceId: { in: resourceIds }, - status: { not: "CANCELLED" }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - select: { - resourceId: true, - projectId: true, - hoursPerDay: true, - startDate: true, - endDate: true, - status: true, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], - }); - const overlappingAssignments = (Array.isArray(overlappingAssignmentsResult) - ? overlappingAssignmentsResult - : []) as BestProjectResourceOverlapAssignmentRecord[]; - - const assignmentsByResourceId = new Map(); - for (const assignment of overlappingAssignments) { - const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; - items.push(assignment); - assignmentsByResourceId.set(assignment.resourceId, items); - } - - const resources = [...resourcesById.values()]; - const contexts = await loadResourceDailyAvailabilityContexts( - 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 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 projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( - (sum, assignment) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }), - 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, - eid: resource.eid, - name: resource.displayName, - role: resource.areaRole?.name ?? null, - chapter: resource.chapter ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - 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, - projectHours: round1(projectHours), - assignmentCount: assignments.length, - holidaySummary: { - count: context?.holidayDates.size ?? 0, - workdayCount: holidayWorkdayCount, - hoursDeduction: round1(holidayHoursDeduction), - holidayDates: [...(context?.holidayDates ?? new Set())].sort(), - }, - 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), - }, - }; - }).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay); - - candidates.sort((left, right) => { - if (input.rankingMode === "highest_remaining_hours_per_day") { - return right.remainingHoursPerDay - left.remainingHoursPerDay - || right.remainingHours - left.remainingHours - || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); - } - if (input.rankingMode === "highest_remaining_hours") { - return right.remainingHours - left.remainingHours - || right.remainingHoursPerDay - left.remainingHoursPerDay - || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); - } - return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER) - || right.remainingHoursPerDay - left.remainingHoursPerDay - || right.remainingHours - left.remainingHours; - }); - - return { - period: { - startDate: toIsoDate(input.startDate), - endDate: toIsoDate(input.endDate), - minHoursPerDay: input.minHoursPerDay, - rankingMode: input.rankingMode, - }, - filters: { - chapter: input.chapter ?? null, - roleName: input.roleName ?? null, - }, - candidateCount: candidates.length, - bestMatch: candidates[0] ?? null, - candidates, - }; -} - export const staffingRouter = createTRPCRouter({ /** * Get ranked resource suggestions for a staffing requirement. @@ -1311,72 +868,5 @@ export const staffingRouter = createTRPCRouter({ return windows; }), - findBestProjectResource: planningReadProcedure - .input( - z.object({ - projectId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - minHoursPerDay: z.number().min(0).default(3), - rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), - chapter: z.string().optional(), - roleName: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.VIEW_COSTS); - return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input); - }), - - getBestProjectResourceDetail: planningReadProcedure - .input( - z.object({ - projectId: z.string().min(1), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - durationDays: z.number().int().min(1).optional(), - minHoursPerDay: z.number().min(0).default(3), - rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), - chapter: z.string().optional(), - roleName: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.VIEW_COSTS); - const project = await findUniqueOrThrow(ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - status: true, - responsiblePerson: true, - }, - }), "Project"); - const { startDate, endDate } = createDateRange({ - startDate: input.startDate, - endDate: input.endDate, - durationDays: input.durationDays, - }); - const result = await queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, { - projectId: input.projectId, - startDate, - endDate, - minHoursPerDay: input.minHoursPerDay, - rankingMode: input.rankingMode, - ...(input.chapter ? { chapter: input.chapter } : {}), - ...(input.roleName ? { roleName: input.roleName } : {}), - }); - - return { - ...result, - project: { - id: project.id, - name: project.name, - shortCode: project.shortCode, - status: project.status, - responsiblePerson: project.responsiblePerson, - }, - }; - }), + ...staffingBestProjectResourceProcedures, });