import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; import { planningReadProcedure, requirePermission } from "../trpc.js"; import { createDateRange, round1, toIsoDate } from "./staffing-shared.js"; import { buildResourceCapacitySummary } from "./staffing-capacity-summary.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 assignments = assignmentsByResourceId.get(resource.id) ?? []; const capacity = buildResourceCapacitySummary({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, bookings: assignments, }); 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, ); 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: 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: capacity.holidaySummary.count, workdayCount: capacity.holidaySummary.workdayCount, hoursDeduction: capacity.holidaySummary.hoursDeduction, holidayDates: capacity.holidaySummary.holidayDates, }, absenceSummary: { dayEquivalent: capacity.absenceSummary.dayEquivalent, hoursDeduction: capacity.absenceSummary.hoursDeduction, }, capacityBreakdown: capacity.capacityBreakdown, }; }).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, }, }; }), };