From a490d68a3b3f5bde4254d420db01b1d3334ac7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:59:26 +0200 Subject: [PATCH] refactor(api): extract resource summary read procedures --- docs/api-router-procedure-support-backlog.md | 2 +- .../api/src/router/resource-read-shared.ts | 133 +++++ ...resource-summary-read-procedure-support.ts | 516 ++++++++++++++++ .../api/src/router/resource-summary-read.ts | 556 +----------------- 4 files changed, 673 insertions(+), 534 deletions(-) create mode 100644 packages/api/src/router/resource-summary-read-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index b328412..6cbe4c4 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -18,6 +18,7 @@ Done - `chargeability-report` - `notification` - `dashboard` +- `resource-summary-read` Ready next - none in the conflict-safe backlog @@ -26,7 +27,6 @@ Deferred or blocked - `assistant-tools` - `entitlement` - `resource-read-shared` -- `resource-summary-read` - `user` - `timeline-router` tests - `vacation-router` tests diff --git a/packages/api/src/router/resource-read-shared.ts b/packages/api/src/router/resource-read-shared.ts index cb5c3f8..f4fcef7 100644 --- a/packages/api/src/router/resource-read-shared.ts +++ b/packages/api/src/router/resource-read-shared.ts @@ -218,6 +218,14 @@ export const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({ })).optional(), }); +const RESOURCE_DIRECTORY_SELECT = { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, +} as const; + export async function listStaffResources( ctx: Pick, input: z.infer, @@ -436,6 +444,131 @@ export async function listStaffResources( return { resources, total, page, limit, nextCursor }; } +export async function listResourceDirectoryEntries( + ctx: Pick, + input: z.infer, +) { + const { + chapter, + chapters, + isActive, + search, + eids, + countryIds, + excludedCountryIds, + includeWithoutCountry, + resourceTypes, + excludedResourceTypes, + includeWithoutResourceType, + rolledOff, + departed, + page, + limit, + cursor, + } = input; + const parsedCursor = parseResourceCursor(cursor); + type WhereClause = Record; + const andClauses: WhereClause[] = []; + const chapterFilters = Array.from( + new Set([ + ...(chapter ? [chapter] : []), + ...(chapters ?? []), + ]), + ); + + if (!eids) { + andClauses.push({ isActive }); + } else { + andClauses.push({ eid: { in: eids } }); + } + if (chapterFilters.length === 1) { + andClauses.push({ chapter: chapterFilters[0] }); + } else if (chapterFilters.length > 1) { + andClauses.push({ chapter: { in: chapterFilters } }); + } + if (search) { + andClauses.push({ + OR: [ + { displayName: { contains: search, mode: "insensitive" as const } }, + { eid: { contains: search, mode: "insensitive" as const } }, + ], + }); + } + if (countryIds && countryIds.length > 0) { + const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }]; + if (includeWithoutCountry) { + countryClauses.push({ countryId: null }); + } + andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses }); + } + if (excludedCountryIds && excludedCountryIds.length > 0) { + andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } }); + } + if (!includeWithoutCountry) { + andClauses.push({ NOT: { countryId: null } }); + } + if (resourceTypes && resourceTypes.length > 0) { + const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }]; + if (includeWithoutResourceType) { + resourceTypeClauses.push({ resourceType: null }); + } + andClauses.push( + resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses }, + ); + } + if (excludedResourceTypes && excludedResourceTypes.length > 0) { + andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } }); + } + if (!includeWithoutResourceType) { + andClauses.push({ NOT: { resourceType: null } }); + } + if (rolledOff !== undefined) { + andClauses.push({ rolledOff }); + } + if (departed !== undefined) { + andClauses.push({ departed }); + } + + const where = andClauses.length > 0 ? { AND: andClauses } : {}; + const skip = cursor ? 0 : (page - 1) * limit; + const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; + const whereWithCursor = parsedCursor + ? { + AND: [ + ...((where as { AND?: WhereClause[] }).AND ?? []), + { + OR: [ + { displayName: { gt: parsedCursor.displayName } }, + { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, + ], + }, + ], + } + : where; + + const [rawResources, total] = await Promise.all([ + ctx.db.resource.findMany({ + where: whereWithCursor, + select: RESOURCE_DIRECTORY_SELECT, + skip, + take: limit + 1, + orderBy, + }), + ctx.db.resource.count({ where }), + ]); + + const hasMore = rawResources.length > limit; + const resources = hasMore ? rawResources.slice(0, limit) : rawResources; + const nextCursor = hasMore + ? JSON.stringify({ + displayName: resources[resources.length - 1]!.displayName, + id: resources[resources.length - 1]!.id, + }) + : null; + + return { resources, total, page, limit, nextCursor }; +} + function buildResourceSummaryWhere(input: { search?: string; country?: string; diff --git a/packages/api/src/router/resource-summary-read-procedure-support.ts b/packages/api/src/router/resource-summary-read-procedure-support.ts new file mode 100644 index 0000000..b4b7a48 --- /dev/null +++ b/packages/api/src/router/resource-summary-read-procedure-support.ts @@ -0,0 +1,516 @@ +import { + calculateAllocation, + deriveResourceForecast, + getMonthRange, + DEFAULT_CALCULATION_RULES, + type AssignmentSlice, +} from "@capakraken/engine"; +import { VacationStatus } from "@capakraken/db"; +import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; +import { z } from "zod"; +import { + asHolidayResolverDb, + collectHolidayAvailability, + getResolvedCalendarHolidays, +} from "../lib/holiday-availability.js"; +import { + calculateEffectiveAvailableHours, + countEffectiveWorkingDays, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { assertCanReadResource } from "../lib/resource-access.js"; +import type { TRPCContext } from "../trpc.js"; +import { + ResourceDirectoryQuerySchema, + ResourceListQuerySchema, + listResourceDirectoryEntries, + listStaffResources, + mapResourceSummary, + mapResourceSummaryDetail, + readResourceSummariesSnapshot, + readResourceSummaryDetailsSnapshot, +} from "./resource-read-shared.js"; + +type ResourceSummaryReadContext = Pick; + +export const ResolveResponsiblePersonNameInputSchema = z.object({ + name: z.string(), +}); + +export const ResourceChargeabilitySummaryInputSchema = z.object({ + resourceId: z.string(), + month: z.string().regex(/^\d{4}-\d{2}$/), +}); + +export const ResourceSummarySnapshotInputSchema = z.object({ + search: z.string().optional(), + country: z.string().optional(), + metroCity: z.string().optional(), + orgUnit: z.string().optional(), + roleName: z.string().optional(), + isActive: z.boolean().optional().default(true), + limit: z.number().int().min(1).max(100).default(50), +}); + +function getAvailabilityHoursForDate( + availability: WeekdayAvailability, + date: Date, +): number { + const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; + return availability[dayKey] ?? 0; +} + +function sumAvailabilityHoursForDates( + availability: WeekdayAvailability, + dates: Date[], +): number { + return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); +} + +function formatResolvedHolidaySummary(holiday: { + date: string; + name: string; + scope: string; + calendarName: string | null; + sourceType?: string | null; +}) { + return { + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + }; +} + +function summarizeResolvedHolidaySummary(holidays: Array>) { + const byScope = new Map(); + const bySourceType = new Map(); + const byCalendar = new Map(); + + for (const holiday of holidays) { + byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); + bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); + byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); + } + + return { + byScope: [...byScope.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([scope, count]) => ({ scope, count })), + bySourceType: [...bySourceType.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sourceType, count]) => ({ sourceType, count })), + byCalendar: [...byCalendar.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([calendarName, count]) => ({ calendarName, count })), + }; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function averagePerWorkingDay(totalHours: number, workingDays: number): number { + return workingDays > 0 ? round1(totalHours / workingDays) : 0; +} + +export async function resolveResponsiblePersonName( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + const exact = await ctx.db.resource.findFirst({ + where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true }, + }); + if (exact) { + return { + status: "resolved" as const, + displayName: exact.displayName, + }; + } + + const candidates = await ctx.db.resource.findMany({ + where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true, eid: true }, + take: 5, + }); + if (candidates.length === 1) { + return { + status: "resolved" as const, + displayName: candidates[0]!.displayName, + }; + } + if (candidates.length > 1) { + return { + status: "ambiguous" as const, + message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, + candidates, + }; + } + + return { + status: "missing" as const, + message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, + candidates: [], + }; +} + +export async function getChargeabilitySummary( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + await assertCanReadResource( + ctx, + input.resourceId, + "You can only view chargeability details for your own resource unless you have staff access", + ); + + const [year, month] = input.month.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + + const resource = await ctx.db.resource.findUniqueOrThrow({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + countryId: true, + federalState: true, + metroCityId: true, + availability: true, + country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, + metroCity: { select: { id: true, name: true } }, + managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, + }, + }); + + const dailyHours = resource.country?.dailyWorkingHours ?? 8; + const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; + const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); + + const availability = resource.availability as WeekdayAvailability | null; + const weeklyAvailability: WeekdayAvailability = availability ?? { + monday: dailyHours, + tuesday: dailyHours, + wednesday: dailyHours, + thursday: dailyHours, + friday: dailyHours, + saturday: 0, + sunday: 0, + }; + + const assignments = await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, + }, + select: { + id: true, + hoursPerDay: true, + startDate: true, + endDate: true, + dailyCostCents: true, + status: true, + project: { + select: { + id: true, + name: true, + shortCode: true, + orderType: true, + utilizationCategory: { select: { code: true } }, + }, + }, + }, + }); + + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: VacationStatus.APPROVED, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + }); + + const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: monthStart, + periodEnd: monthEnd, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }); + const holidayAvailability = collectHolidayAvailability({ + vacations, + periodStart: monthStart, + periodEnd: monthEnd, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name, + resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), + }); + const absenceDays = holidayAvailability.absenceDays; + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability: weeklyAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + monthStart, + monthEnd, + ); + const availabilityContext = contexts.get(resource.id); + + let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; + try { + const dbRules = await ctx.db.calculationRule.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + if (dbRules.length > 0) { + calcRules = dbRules as unknown as CalculationRule[]; + } + } catch { + // table may not exist yet + } + + const baseWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + + const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); + const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( + count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( + weeklyAvailability, + publicHolidayDates, + ); + const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + return sum + (absence.isHalfDay ? 0.5 : 1); + }, 0); + const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); + return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); + }, 0); + + const slices: AssignmentSlice[] = []; + const assignmentBreakdown: Array<{ + project: string; + code: string; + hours: number; + status: string; + }> = []; + let totalBookedHours = 0; + + for (const assignment of assignments) { + const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); + const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; + + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: assignment.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + orderType: assignment.project.orderType, + projectId: assignment.project.id, + }); + if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { + continue; + } + + totalBookedHours += calcResult.totalHours; + assignmentBreakdown.push({ + project: assignment.project.name, + code: assignment.project.shortCode, + hours: round1(calcResult.totalHours), + status: assignment.status, + }); + slices.push({ + hoursPerDay: assignment.hoursPerDay, + workingDays: calcResult.workingDays, + categoryCode, + ...(calcResult.totalChargeableHours !== undefined + ? { totalChargeableHours: calcResult.totalChargeableHours } + : {}), + }); + } + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetRatio, + assignments: slices, + sah: effectiveAvailableHours, + }); + + const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ + ...holiday, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + })); + const workingDays = round1(effectiveWorkingDays); + const baseWorkingDaysRounded = round1(baseWorkingDays); + const baseAvailableHoursRounded = round1(baseAvailableHours); + const availableHours = round1(effectiveAvailableHours); + const bookedHours = round1(totalBookedHours); + const targetPct = round1(targetRatio * 100); + const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; + const chargeabilityPct = round1(forecast.chg * 100); + const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); + + return { + resource: resource.displayName, + eid: resource.eid, + month: input.month, + periodStart: monthStart.toISOString().slice(0, 10), + periodEnd: monthEnd.toISOString().slice(0, 10), + fte: round1(resource.fte), + target: `${targetPct}%`, + targetPct, + targetHours, + workingDays, + baseWorkingDays: baseWorkingDaysRounded, + locationContext: { + countryCode: resource.country?.code ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCity: resource.metroCity?.name ?? null, + }, + baseAvailableHours: baseAvailableHoursRounded, + availableHours, + bookedHours, + unassignedHours, + chargeability: `${chargeabilityPct}%`, + chargeabilityPct, + onTarget: chargeabilityPct >= targetPct, + holidaySummary: { + count: formattedHolidays.length, + workdayCount: round1(publicHolidayWorkdayCount), + hoursDeduction: round1(publicHolidayHoursDeduction), + holidays: formattedHolidays, + breakdown: summarizeResolvedHolidaySummary(formattedHolidays), + }, + absenceSummary: { + dayEquivalent: round1(absenceDayEquivalent), + hoursDeduction: round1(absenceHoursDeduction), + }, + capacityBreakdown: { + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + baseAvailableHours: baseAvailableHoursRounded, + holidayHoursDeduction: round1(publicHolidayHoursDeduction), + absenceHoursDeduction: round1(absenceHoursDeduction), + availableHours, + }, + averages: { + availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), + bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), + remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), + }, + allocations: assignmentBreakdown, + scheduleContext: { + dailyWorkingHours: dailyHours, + hasScheduleRules: Boolean(scheduleRules), + }, + }; +} + +export async function listResourceSummaries( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + const resources = await readResourceSummariesSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummary); +} + +export async function listResourceSummaryDetails( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + const resources = await readResourceSummaryDetailsSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummaryDetail); +} + +export async function listResourceDirectory( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + return listResourceDirectoryEntries(ctx, input); +} + +export async function listResourceChapters(ctx: ResourceSummaryReadContext) { + const resources = await ctx.db.resource.findMany({ + where: { isActive: true, chapter: { not: null } }, + select: { chapter: true }, + distinct: ["chapter"], + orderBy: { chapter: "asc" }, + }); + return resources.map((resource) => resource.chapter as string); +} + +export async function listStaffResourceEntries( + ctx: ResourceSummaryReadContext, + input: z.infer, +) { + return listStaffResources(ctx, input); +} diff --git a/packages/api/src/router/resource-summary-read.ts b/packages/api/src/router/resource-summary-read.ts index 6cc6a89..cb6e57b 100644 --- a/packages/api/src/router/resource-summary-read.ts +++ b/packages/api/src/router/resource-summary-read.ts @@ -1,555 +1,45 @@ -import { - calculateAllocation, - deriveResourceForecast, - getMonthRange, - DEFAULT_CALCULATION_RULES, - type AssignmentSlice, -} from "@capakraken/engine"; -import { VacationStatus } from "@capakraken/db"; -import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; -import { z } from "zod"; -import { - asHolidayResolverDb, - collectHolidayAvailability, - getResolvedCalendarHolidays, -} from "../lib/holiday-availability.js"; -import { - calculateEffectiveAvailableHours, - countEffectiveWorkingDays, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; -import { assertCanReadResource } from "../lib/resource-access.js"; import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js"; import { ResourceDirectoryQuerySchema, ResourceListQuerySchema, - listStaffResources, - mapResourceSummary, - mapResourceSummaryDetail, - readResourceSummariesSnapshot, - readResourceSummaryDetailsSnapshot, } from "./resource-read-shared.js"; - -function getAvailabilityHoursForDate( - availability: WeekdayAvailability, - date: Date, -): number { - const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; - return availability[dayKey] ?? 0; -} - -function sumAvailabilityHoursForDates( - availability: WeekdayAvailability, - dates: Date[], -): number { - return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); -} - -function formatResolvedHolidaySummary(holiday: { - date: string; - name: string; - scope: string; - calendarName: string | null; - sourceType?: string | null; -}) { - return { - date: holiday.date, - name: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName ?? "Built-in", - sourceType: holiday.sourceType ?? "system", - }; -} - -function summarizeResolvedHolidaySummary(holidays: Array>) { - const byScope = new Map(); - const bySourceType = new Map(); - const byCalendar = new Map(); - - for (const holiday of holidays) { - byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); - bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); - byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); - } - - return { - byScope: [...byScope.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([scope, count]) => ({ scope, count })), - bySourceType: [...bySourceType.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([sourceType, count]) => ({ sourceType, count })), - byCalendar: [...byCalendar.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([calendarName, count]) => ({ calendarName, count })), - }; -} - -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - -function averagePerWorkingDay(totalHours: number, workingDays: number): number { - return workingDays > 0 ? round1(totalHours / workingDays) : 0; -} +import { + ResolveResponsiblePersonNameInputSchema, + ResourceChargeabilitySummaryInputSchema, + ResourceSummarySnapshotInputSchema, + getChargeabilitySummary, + listResourceChapters, + listResourceDirectory, + listResourceSummaries, + listResourceSummaryDetails, + listStaffResourceEntries, + resolveResponsiblePersonName, +} from "./resource-summary-read-procedure-support.js"; export const resourceSummaryReadProcedures = { resolveResponsiblePersonName: resourceOverviewProcedure - .input(z.object({ name: z.string() })) - .query(async ({ ctx, input }) => { - const exact = await ctx.db.resource.findFirst({ - where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, - select: { displayName: true }, - }); - if (exact) { - return { - status: "resolved" as const, - displayName: exact.displayName, - }; - } - - const candidates = await ctx.db.resource.findMany({ - where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, - select: { displayName: true, eid: true }, - take: 5, - }); - if (candidates.length === 1) { - return { - status: "resolved" as const, - displayName: candidates[0]!.displayName, - }; - } - if (candidates.length > 1) { - return { - status: "ambiguous" as const, - message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, - candidates, - }; - } - - return { - status: "missing" as const, - message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, - candidates: [], - }; - }), + .input(ResolveResponsiblePersonNameInputSchema) + .query(({ ctx, input }) => resolveResponsiblePersonName(ctx, input)), getChargeabilitySummary: protectedProcedure - .input(z.object({ - resourceId: z.string(), - month: z.string().regex(/^\d{4}-\d{2}$/), - })) - .query(async ({ ctx, input }) => { - await assertCanReadResource( - ctx, - input.resourceId, - "You can only view chargeability details for your own resource unless you have staff access", - ); - - const [year, month] = input.month.split("-").map(Number) as [number, number]; - const { start: monthStart, end: monthEnd } = getMonthRange(year, month); - - const resource = await ctx.db.resource.findUniqueOrThrow({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - eid: true, - fte: true, - lcrCents: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - availability: true, - country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, - metroCity: { select: { id: true, name: true } }, - managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, - }, - }); - - const dailyHours = resource.country?.dailyWorkingHours ?? 8; - const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; - const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); - - const availability = resource.availability as WeekdayAvailability | null; - const weeklyAvailability: WeekdayAvailability = availability ?? { - monday: dailyHours, - tuesday: dailyHours, - wednesday: dailyHours, - thursday: dailyHours, - friday: dailyHours, - saturday: 0, - sunday: 0, - }; - - const assignments = await ctx.db.assignment.findMany({ - where: { - resourceId: input.resourceId, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, - }, - select: { - id: true, - hoursPerDay: true, - startDate: true, - endDate: true, - dailyCostCents: true, - status: true, - project: { - select: { - id: true, - name: true, - shortCode: true, - orderType: true, - utilizationCategory: { select: { code: true } }, - }, - }, - }, - }); - - const vacations = await ctx.db.vacation.findMany({ - where: { - resourceId: input.resourceId, - status: VacationStatus.APPROVED, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - }, - select: { startDate: true, endDate: true, type: true, isHalfDay: true }, - }); - - const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: monthStart, - periodEnd: monthEnd, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }); - const holidayAvailability = collectHolidayAvailability({ - vacations, - periodStart: monthStart, - periodEnd: monthEnd, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name, - resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), - }); - const absenceDays = holidayAvailability.absenceDays; - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability: weeklyAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - monthStart, - monthEnd, - ); - const availabilityContext = contexts.get(resource.id); - - let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; - try { - const dbRules = await ctx.db.calculationRule.findMany({ - where: { isActive: true }, - orderBy: [{ priority: "desc" }], - }); - if (dbRules.length > 0) { - calcRules = dbRules as unknown as CalculationRule[]; - } - } catch { - // table may not exist yet - } - - const baseWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - - const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); - const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( - count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) - ), 0); - const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( - weeklyAvailability, - publicHolidayDates, - ); - const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - return sum + (absence.isHalfDay ? 0.5 : 1); - }, 0); - const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); - return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); - }, 0); - - const slices: AssignmentSlice[] = []; - const assignmentBreakdown: Array<{ - project: string; - code: string; - hours: number; - status: string; - }> = []; - let totalBookedHours = 0; - - for (const assignment of assignments) { - const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); - const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); - const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; - - const calcResult = calculateAllocation({ - lcrCents: resource.lcrCents, - hoursPerDay: assignment.hoursPerDay, - startDate: overlapStart, - endDate: overlapEnd, - availability: weeklyAvailability, - absenceDays, - calculationRules: calcRules, - orderType: assignment.project.orderType, - projectId: assignment.project.id, - }); - if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { - continue; - } - - totalBookedHours += calcResult.totalHours; - assignmentBreakdown.push({ - project: assignment.project.name, - code: assignment.project.shortCode, - hours: round1(calcResult.totalHours), - status: assignment.status, - }); - slices.push({ - hoursPerDay: assignment.hoursPerDay, - workingDays: calcResult.workingDays, - categoryCode, - ...(calcResult.totalChargeableHours !== undefined - ? { totalChargeableHours: calcResult.totalChargeableHours } - : {}), - }); - } - - const forecast = deriveResourceForecast({ - fte: resource.fte, - targetPercentage: targetRatio, - assignments: slices, - sah: effectiveAvailableHours, - }); - - const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ - ...holiday, - calendarName: holiday.calendarName ?? "Built-in", - sourceType: holiday.sourceType ?? "system", - })); - const workingDays = round1(effectiveWorkingDays); - const baseWorkingDaysRounded = round1(baseWorkingDays); - const baseAvailableHoursRounded = round1(baseAvailableHours); - const availableHours = round1(effectiveAvailableHours); - const bookedHours = round1(totalBookedHours); - const targetPct = round1(targetRatio * 100); - const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; - const chargeabilityPct = round1(forecast.chg * 100); - const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); - - return { - resource: resource.displayName, - eid: resource.eid, - month: input.month, - periodStart: monthStart.toISOString().slice(0, 10), - periodEnd: monthEnd.toISOString().slice(0, 10), - fte: round1(resource.fte), - target: `${targetPct}%`, - targetPct, - targetHours, - workingDays, - baseWorkingDays: baseWorkingDaysRounded, - locationContext: { - countryCode: resource.country?.code ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCity: resource.metroCity?.name ?? null, - }, - baseAvailableHours: baseAvailableHoursRounded, - availableHours, - bookedHours, - unassignedHours, - chargeability: `${chargeabilityPct}%`, - chargeabilityPct, - onTarget: chargeabilityPct >= targetPct, - holidaySummary: { - count: formattedHolidays.length, - workdayCount: round1(publicHolidayWorkdayCount), - hoursDeduction: round1(publicHolidayHoursDeduction), - holidays: formattedHolidays, - breakdown: summarizeResolvedHolidaySummary(formattedHolidays), - }, - absenceSummary: { - dayEquivalent: round1(absenceDayEquivalent), - hoursDeduction: round1(absenceHoursDeduction), - }, - capacityBreakdown: { - formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", - baseAvailableHours: baseAvailableHoursRounded, - holidayHoursDeduction: round1(publicHolidayHoursDeduction), - absenceHoursDeduction: round1(absenceHoursDeduction), - availableHours, - }, - averages: { - availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), - bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), - remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), - }, - allocations: assignmentBreakdown, - scheduleContext: { - dailyWorkingHours: dailyHours, - hasScheduleRules: Boolean(scheduleRules), - }, - }; - }), + .input(ResourceChargeabilitySummaryInputSchema) + .query(({ ctx, input }) => getChargeabilitySummary(ctx, input)), listSummaries: resourceOverviewProcedure - .input(z.object({ - search: z.string().optional(), - country: z.string().optional(), - metroCity: z.string().optional(), - orgUnit: z.string().optional(), - roleName: z.string().optional(), - isActive: z.boolean().optional().default(true), - limit: z.number().int().min(1).max(100).default(50), - })) - .query(async ({ ctx, input }) => { - const resources = await readResourceSummariesSnapshot(ctx, { - isActive: input.isActive, - limit: input.limit, - ...(input.search ? { search: input.search } : {}), - ...(input.country ? { country: input.country } : {}), - ...(input.metroCity ? { metroCity: input.metroCity } : {}), - ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), - ...(input.roleName ? { roleName: input.roleName } : {}), - }); - return resources.map(mapResourceSummary); - }), + .input(ResourceSummarySnapshotInputSchema) + .query(({ ctx, input }) => listResourceSummaries(ctx, input)), listSummariesDetail: resourceOverviewProcedure - .input(z.object({ - search: z.string().optional(), - country: z.string().optional(), - metroCity: z.string().optional(), - orgUnit: z.string().optional(), - roleName: z.string().optional(), - isActive: z.boolean().optional().default(true), - limit: z.number().int().min(1).max(100).default(50), - })) - .query(async ({ ctx, input }) => { - const resources = await readResourceSummaryDetailsSnapshot(ctx, { - isActive: input.isActive, - limit: input.limit, - ...(input.search ? { search: input.search } : {}), - ...(input.country ? { country: input.country } : {}), - ...(input.metroCity ? { metroCity: input.metroCity } : {}), - ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), - ...(input.roleName ? { roleName: input.roleName } : {}), - }); - return resources.map(mapResourceSummaryDetail); - }), + .input(ResourceSummarySnapshotInputSchema) + .query(({ ctx, input }) => listResourceSummaryDetails(ctx, input)), directory: protectedProcedure .input(ResourceDirectoryQuerySchema) - .query(async ({ ctx, input }) => { - const { - chapter, - chapters, - isActive, - search, - eids, - countryIds, - excludedCountryIds, - includeWithoutCountry, - resourceTypes, - excludedResourceTypes, - includeWithoutResourceType, - rolledOff, - departed, - page, - limit, - cursor, - } = input; - - const resources = await listStaffResources(ctx, { - chapter, - chapters, - isActive, - search, - eids, - countryIds, - excludedCountryIds, - includeWithoutCountry, - resourceTypes, - excludedResourceTypes, - includeWithoutResourceType, - rolledOff, - departed, - page, - limit, - cursor, - includeRoles: false, - }); - - return { - ...resources, - resources: resources.resources.map((resource) => ({ - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - chapter: resource.chapter, - isActive: resource.isActive, - })), - }; - }), + .query(({ ctx, input }) => listResourceDirectory(ctx, input)), listStaff: resourceOverviewProcedure .input(ResourceListQuerySchema) - .query(async ({ ctx, input }) => listStaffResources(ctx, input)), + .query(({ ctx, input }) => listStaffResourceEntries(ctx, input)), - chapters: protectedProcedure.query(async ({ ctx }) => { - const resources = await ctx.db.resource.findMany({ - where: { isActive: true, chapter: { not: null } }, - select: { chapter: true }, - distinct: ["chapter"], - orderBy: { chapter: "asc" }, - }); - return resources.map((resource) => resource.chapter as string); - }), + chapters: protectedProcedure.query(({ ctx }) => listResourceChapters(ctx)), };