diff --git a/packages/api/src/router/report-query-engine.ts b/packages/api/src/router/report-query-engine.ts index 9903ee9..2141d63 100644 --- a/packages/api/src/router/report-query-engine.ts +++ b/packages/api/src/router/report-query-engine.ts @@ -1,19 +1,6 @@ -import { - isChargeabilityActualBooking, - isChargeabilityRelevantProject, - listAssignmentBookings, -} from "@capakraken/application"; import { TRPCError } from "@trpc/server"; -import type { WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { controllerProcedure } from "../trpc.js"; -import { - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - countEffectiveWorkingDays, - getAvailabilityHoursForDate, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; import { buildSelect, buildWhere, @@ -27,13 +14,9 @@ import { type ReportQueryResult, validateReportInput, } from "./report-query-config.js"; -import { COLUMN_MAP, RESOURCE_MONTH_COLUMNS } from "./report-columns.js"; -import { - buildReportGroups, - matchesInMemoryFilter, - pickColumns, - sortInMemoryRows, -} from "./report-query-utils.js"; +import { COLUMN_MAP } from "./report-columns.js"; +import { buildReportGroups, pickColumns, sortInMemoryRows } from "./report-query-utils.js"; +import { executeResourceMonthReport } from "./report-resource-month-query.js"; export const reportQueryProcedures = { getAvailableColumns: controllerProcedure @@ -147,219 +130,6 @@ async function executeReportQuery( }; } -async function executeResourceMonthReport( - db: any, - input: ReportInput, -): Promise { - const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7); - const [year, month] = periodMonth.split("-").map(Number) as [number, number]; - const periodStart = new Date(Date.UTC(year, month - 1, 1)); - const periodEnd = new Date(Date.UTC(year, month, 0)); - - const resources = await db.resource.findMany({ - select: { - id: true, - eid: true, - displayName: true, - email: true, - chapter: true, - resourceType: true, - isActive: true, - chgResponsibility: true, - rolledOff: true, - departed: true, - lcrCents: true, - ucrCents: true, - currency: true, - fte: true, - availability: true, - chargeabilityTarget: true, - federalState: true, - countryId: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - orgUnit: { select: { name: true } }, - managementLevelGroup: { select: { name: true, targetPercentage: true } }, - managementLevel: { select: { name: true } }, - }, - orderBy: { displayName: "asc" }, - }); - - const resourceIds = resources.map((resource: any) => resource.id); - const [bookings, contexts] = await Promise.all([ - resourceIds.length > 0 - ? listAssignmentBookings(db, { - startDate: periodStart, - endDate: periodEnd, - resourceIds, - }) - : Promise.resolve([]), - loadResourceDailyAvailabilityContexts( - db, - resources.map((resource: any) => ({ - id: resource.id, - availability: resource.availability as WeekdayAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - })), - periodStart, - periodEnd, - ), - ]); - - const rows = resources.map((resource: any) => { - const availability = resource.availability as WeekdayAvailability; - const context = contexts.get(resource.id); - const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); - const baseWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart, - periodEnd, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart, - periodEnd, - context, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart, - periodEnd, - context: undefined, - }); - const sahHours = calculateEffectiveAvailableHours({ - availability, - periodStart, - periodEnd, - context, - }); - - const holidayDates = [...(context?.holidayDates ?? new Set())]; - const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( - count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) - ), 0); - const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( - sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) - ), 0); - - let absenceDayEquivalent = 0; - let absenceHoursDeduction = 0; - for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { - const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)); - if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { - continue; - } - absenceDayEquivalent += fraction; - absenceHoursDeduction += dayHours * fraction; - } - - const actualBookedHours = resourceBookings - .filter((booking) => isChargeabilityActualBooking(booking, false)) - .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart, - periodEnd, - context, - }), 0); - const expectedBookedHours = resourceBookings - .filter((booking) => isChargeabilityRelevantProject(booking.project, true)) - .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart, - periodEnd, - context, - }), 0); - - const targetPct = resource.managementLevelGroup?.targetPercentage != null - ? resource.managementLevelGroup.targetPercentage * 100 - : resource.chargeabilityTarget; - - return { - id: `${resource.id}:${periodMonth}`, - resourceId: resource.id, - monthKey: periodMonth, - periodStart: periodStart.toISOString(), - periodEnd: periodEnd.toISOString(), - eid: resource.eid, - displayName: resource.displayName, - email: resource.email, - chapter: resource.chapter, - resourceType: resource.resourceType, - isActive: resource.isActive, - chgResponsibility: resource.chgResponsibility, - rolledOff: resource.rolledOff, - departed: resource.departed, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name ?? null, - orgUnitName: resource.orgUnit?.name ?? null, - managementLevelGroupName: resource.managementLevelGroup?.name ?? null, - managementLevelName: resource.managementLevel?.name ?? null, - fte: roundMetric(resource.fte), - lcrCents: resource.lcrCents, - ucrCents: resource.ucrCents, - currency: resource.currency, - monthlyChargeabilityTargetPct: roundMetric(targetPct), - monthlyTargetHours: roundMetric((sahHours * targetPct) / 100), - monthlyBaseWorkingDays: roundMetric(baseWorkingDays), - monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays), - monthlyBaseAvailableHours: roundMetric(baseAvailableHours), - monthlySahHours: roundMetric(sahHours), - monthlyPublicHolidayCount: holidayDates.length, - monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount, - monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction), - monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent), - monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction), - monthlyActualBookedHours: roundMetric(actualBookedHours), - monthlyExpectedBookedHours: roundMetric(expectedBookedHours), - monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0), - monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0), - monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)), - }; - }); - - const filteredRows = rows.filter((row: Record) => input.filters.every((filter) => matchesInMemoryFilter( - row, - filter, - RESOURCE_MONTH_COLUMNS, - ))); - const sortedRows = sortInMemoryRows( - filteredRows, - input.groupBy, - input.sortBy, - input.sortDir, - RESOURCE_MONTH_COLUMNS, - ); - const totalCount = sortedRows.length; - const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit); - const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")]; - const groups = buildReportGroups(pagedRows, input.groupBy); - - return { - rows: pagedRows.map((row) => pickColumns(row, outputColumns)), - columns: outputColumns, - totalCount, - groups, - }; -} - -function roundMetric(value: number): number { - return Math.round(value * 10) / 10; -} - function getModelDelegate(db: any, entity: EntityKey) { switch (entity) { case "resource": diff --git a/packages/api/src/router/report-resource-month-query.ts b/packages/api/src/router/report-resource-month-query.ts new file mode 100644 index 0000000..66e7059 --- /dev/null +++ b/packages/api/src/router/report-resource-month-query.ts @@ -0,0 +1,234 @@ +import { + isChargeabilityActualBooking, + isChargeabilityRelevantProject, + listAssignmentBookings, +} from "@capakraken/application"; +import type { WeekdayAvailability } from "@capakraken/shared"; +import { + calculateEffectiveAvailableHours, + calculateEffectiveBookedHours, + countEffectiveWorkingDays, + getAvailabilityHoursForDate, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import type { ReportInput, ReportQueryResult } from "./report-query-config.js"; +import { RESOURCE_MONTH_COLUMNS } from "./report-columns.js"; +import { + buildReportGroups, + matchesInMemoryFilter, + pickColumns, + sortInMemoryRows, +} from "./report-query-utils.js"; + +export async function executeResourceMonthReport( + db: any, + input: ReportInput, +): Promise { + const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7); + const [year, month] = periodMonth.split("-").map(Number) as [number, number]; + const periodStart = new Date(Date.UTC(year, month - 1, 1)); + const periodEnd = new Date(Date.UTC(year, month, 0)); + + const resources = await db.resource.findMany({ + select: { + id: true, + eid: true, + displayName: true, + email: true, + chapter: true, + resourceType: true, + isActive: true, + chgResponsibility: true, + rolledOff: true, + departed: true, + lcrCents: true, + ucrCents: true, + currency: true, + fte: true, + availability: true, + chargeabilityTarget: true, + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + orgUnit: { select: { name: true } }, + managementLevelGroup: { select: { name: true, targetPercentage: true } }, + managementLevel: { select: { name: true } }, + }, + orderBy: { displayName: "asc" }, + }); + + const resourceIds = resources.map((resource: any) => resource.id); + const [bookings, contexts] = await Promise.all([ + resourceIds.length > 0 + ? listAssignmentBookings(db, { + startDate: periodStart, + endDate: periodEnd, + resourceIds, + }) + : Promise.resolve([]), + loadResourceDailyAvailabilityContexts( + db, + resources.map((resource: any) => ({ + id: resource.id, + availability: resource.availability as WeekdayAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + })), + periodStart, + periodEnd, + ), + ]); + + const rows = resources.map((resource: any) => { + const availability = resource.availability as WeekdayAvailability; + const context = contexts.get(resource.id); + const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); + const baseWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart, + periodEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart, + periodEnd, + context, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability, + periodStart, + periodEnd, + context: undefined, + }); + const sahHours = calculateEffectiveAvailableHours({ + availability, + periodStart, + periodEnd, + context, + }); + + const holidayDates = [...(context?.holidayDates ?? new Set())]; + const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( + count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( + sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) + ), 0); + + let absenceDayEquivalent = 0; + let absenceHoursDeduction = 0; + for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { + const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)); + if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { + continue; + } + absenceDayEquivalent += fraction; + absenceHoursDeduction += dayHours * fraction; + } + + const actualBookedHours = resourceBookings + .filter((booking) => isChargeabilityActualBooking(booking, false)) + .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart, + periodEnd, + context, + }), 0); + const expectedBookedHours = resourceBookings + .filter((booking) => isChargeabilityRelevantProject(booking.project, true)) + .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart, + periodEnd, + context, + }), 0); + + const targetPct = resource.managementLevelGroup?.targetPercentage != null + ? resource.managementLevelGroup.targetPercentage * 100 + : resource.chargeabilityTarget; + + return { + id: `${resource.id}:${periodMonth}`, + resourceId: resource.id, + monthKey: periodMonth, + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + eid: resource.eid, + displayName: resource.displayName, + email: resource.email, + chapter: resource.chapter, + resourceType: resource.resourceType, + isActive: resource.isActive, + chgResponsibility: resource.chgResponsibility, + rolledOff: resource.rolledOff, + departed: resource.departed, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name ?? null, + orgUnitName: resource.orgUnit?.name ?? null, + managementLevelGroupName: resource.managementLevelGroup?.name ?? null, + managementLevelName: resource.managementLevel?.name ?? null, + fte: roundMetric(resource.fte), + lcrCents: resource.lcrCents, + ucrCents: resource.ucrCents, + currency: resource.currency, + monthlyChargeabilityTargetPct: roundMetric(targetPct), + monthlyTargetHours: roundMetric((sahHours * targetPct) / 100), + monthlyBaseWorkingDays: roundMetric(baseWorkingDays), + monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays), + monthlyBaseAvailableHours: roundMetric(baseAvailableHours), + monthlySahHours: roundMetric(sahHours), + monthlyPublicHolidayCount: holidayDates.length, + monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount, + monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction), + monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent), + monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction), + monthlyActualBookedHours: roundMetric(actualBookedHours), + monthlyExpectedBookedHours: roundMetric(expectedBookedHours), + monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0), + monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0), + monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)), + }; + }); + + const filteredRows = rows.filter((row: Record) => input.filters.every((filter) => matchesInMemoryFilter( + row, + filter, + RESOURCE_MONTH_COLUMNS, + ))); + const sortedRows = sortInMemoryRows( + filteredRows, + input.groupBy, + input.sortBy, + input.sortDir, + RESOURCE_MONTH_COLUMNS, + ); + const totalCount = sortedRows.length; + const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit); + const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")]; + const groups = buildReportGroups(pagedRows, input.groupBy); + + return { + rows: pagedRows.map((row) => pickColumns(row, outputColumns)), + columns: outputColumns, + totalCount, + groups, + }; +} + +function roundMetric(value: number): number { + return Math.round(value * 10) / 10; +}