Files
CapaKraken/packages/api/src/router/resource-capacity-read.ts
T

265 lines
9.1 KiB
TypeScript

import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import {
anonymizeResource,
getAnonymizationDirectory,
} from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { controllerProcedure } from "../trpc.js";
import { buildDailyBookedHoursMap } from "./resource-capacity-shared.js";
export const resourceCapacityReadProcedures = {
listWithUtilization: controllerProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
chapter: z.string().optional(),
includeProposed: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
take: input.limit,
orderBy: { displayName: "asc" },
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
availability: true,
skills: true,
dynamicFields: true,
blueprintId: true,
isActive: true,
createdAt: true,
updatedAt: true,
roleId: true,
portfolioUrl: true,
postalCode: true,
federalState: true,
countryId: true,
metroCityId: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
items.push(booking);
bookingsByResourceId.set(booking.resourceId, items);
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.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,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = (bookingsByResourceId.get(resource.id) ?? []).filter(
(booking) =>
booking.resourceId === resource.id &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: start,
periodEnd: end,
context,
});
const bookedHours = resourceBookings.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
const date = new Date(`${isoDate}T00:00:00.000Z`);
const dayCapacity = calculateEffectiveDayAvailability({
availability,
date,
context,
});
return dayCapacity > 0 && hours > dayCapacity;
});
const utilizationPercent = availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
return anonymizeResource({
...resource,
bookingCount: resourceBookings.length,
bookedHours: Math.round(bookedHours),
availableHours: Math.round(availableHours),
utilizationPercent,
isOverbooked,
}, directory);
});
}),
getChargeabilityStats: controllerProcedure
.input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceId ? { id: input.resourceId } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.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,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const actualAllocs = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed),
);
const expectedAllocs = resourceBookings.filter((booking) =>
isChargeabilityRelevantProject(booking.project, true),
);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: start,
periodEnd: end,
context,
});
const actualBookedHours = actualAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const expectedBookedHours = expectedAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const actualChargeability = availableHours > 0
? Math.round((actualBookedHours / availableHours) * 100)
: 0;
const expectedChargeability = availableHours > 0
? Math.round((expectedBookedHours / availableHours) * 100)
: 0;
return anonymizeResource({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability,
expectedChargeability,
availableHours: Math.round(availableHours),
}, directory);
});
}),
};