refactor(api): split report query execution
This commit is contained in:
@@ -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<ReportQueryResult> {
|
||||
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<string>())];
|
||||
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<string, unknown>) => 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":
|
||||
|
||||
@@ -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<ReportQueryResult> {
|
||||
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<string>())];
|
||||
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<string, unknown>) => 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;
|
||||
}
|
||||
Reference in New Issue
Block a user