386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
import { listAssignmentBookings } from "@capakraken/application";
|
|
import { type WeekdayAvailability } from "@capakraken/shared";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import {
|
|
calculateEffectiveAvailableHours,
|
|
calculateEffectiveBookedHours,
|
|
countEffectiveWorkingDays,
|
|
loadResourceDailyAvailabilityContexts,
|
|
} from "../lib/resource-capacity.js";
|
|
import { planningReadProcedure } from "../trpc.js";
|
|
import {
|
|
ACTIVE_STATUSES,
|
|
averagePerWorkingDay,
|
|
calculateAllocatedHoursForDay,
|
|
getEffectiveDayAvailability,
|
|
round1,
|
|
toIsoDate,
|
|
} from "./staffing-shared.js";
|
|
|
|
export const staffingCapacityReadProcedures = {
|
|
searchCapacity: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
minHoursPerDay: z.number().optional().default(4),
|
|
roleName: z.string().optional(),
|
|
chapter: z.string().optional(),
|
|
limit: z.number().int().min(1).max(100).optional().default(20),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Record<string, unknown> = { isActive: true };
|
|
if (input.roleName) {
|
|
where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } };
|
|
}
|
|
if (input.chapter) {
|
|
where.chapter = { contains: input.chapter, mode: "insensitive" };
|
|
}
|
|
|
|
const resources = await ctx.db.resource.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
eid: true,
|
|
fte: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true } },
|
|
metroCity: { select: { name: true } },
|
|
areaRole: { select: { name: true } },
|
|
chapter: true,
|
|
},
|
|
take: 100,
|
|
});
|
|
|
|
const bookings = await listAssignmentBookings(ctx.db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
resourceIds: resources.map((resource) => resource.id),
|
|
});
|
|
|
|
const bookingsByResourceId = new Map<string, typeof bookings>();
|
|
for (const booking of bookings) {
|
|
if (!booking.resourceId) {
|
|
continue;
|
|
}
|
|
const existing = bookingsByResourceId.get(booking.resourceId) ?? [];
|
|
existing.push(booking);
|
|
bookingsByResourceId.set(booking.resourceId, existing);
|
|
}
|
|
|
|
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,
|
|
})),
|
|
input.startDate,
|
|
input.endDate,
|
|
);
|
|
|
|
const results = resources
|
|
.map((resource) => {
|
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
const context = contexts.get(resource.id);
|
|
const workingDays = countEffectiveWorkingDays({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
});
|
|
const availableHours = calculateEffectiveAvailableHours({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
});
|
|
const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce(
|
|
(sum, booking) =>
|
|
sum + calculateEffectiveBookedHours({
|
|
availability,
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
}),
|
|
0,
|
|
);
|
|
const remainingHours = Math.max(0, availableHours - bookedHours);
|
|
|
|
return {
|
|
id: resource.id,
|
|
name: resource.displayName,
|
|
eid: resource.eid,
|
|
role: resource.areaRole?.name ?? null,
|
|
chapter: resource.chapter,
|
|
workingDays,
|
|
availableHours: round1(remainingHours),
|
|
availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays),
|
|
};
|
|
})
|
|
.filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay)
|
|
.sort((left, right) => right.availableHours - left.availableHours)
|
|
.slice(0, input.limit);
|
|
|
|
return {
|
|
period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`,
|
|
minHoursFilter: input.minHoursPerDay,
|
|
results,
|
|
totalFound: results.length,
|
|
};
|
|
}),
|
|
|
|
analyzeUtilization: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
resourceId: z.string(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const resource = await findUniqueOrThrow(
|
|
ctx.db.resource.findUnique({
|
|
where: { id: input.resourceId },
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
chargeabilityTarget: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true } },
|
|
metroCity: { select: { name: true } },
|
|
},
|
|
}),
|
|
"Resource",
|
|
);
|
|
|
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
resourceIds: [resource.id],
|
|
});
|
|
|
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
ctx.db,
|
|
[{
|
|
id: resource.id,
|
|
availability,
|
|
countryId: resource.countryId,
|
|
countryCode: resource.country?.code,
|
|
federalState: resource.federalState,
|
|
metroCityId: resource.metroCityId,
|
|
metroCityName: resource.metroCity?.name,
|
|
}],
|
|
input.startDate,
|
|
input.endDate,
|
|
);
|
|
const context = contexts.get(resource.id);
|
|
const activeBookings = resourceBookings.map((booking) => ({
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
status: booking.status,
|
|
projectName: booking.project.name,
|
|
isChargeable: booking.project.orderType === "CHARGEABLE",
|
|
}));
|
|
|
|
const overallocatedDays: string[] = [];
|
|
const underutilizedDays: string[] = [];
|
|
let totalAvailableHours = 0;
|
|
let totalChargeableHours = 0;
|
|
const cursor = new Date(input.startDate);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(input.endDate);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
|
if (availableHoursForDay > 0) {
|
|
const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({
|
|
bookings: activeBookings,
|
|
date: cursor,
|
|
context,
|
|
});
|
|
totalAvailableHours += availableHoursForDay;
|
|
totalChargeableHours += chargeableHours;
|
|
|
|
if (allocatedHours > availableHoursForDay) {
|
|
overallocatedDays.push(toIsoDate(cursor));
|
|
} else if (allocatedHours < availableHoursForDay * 0.5) {
|
|
underutilizedDays.push(toIsoDate(cursor));
|
|
}
|
|
}
|
|
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
const currentChargeability = totalAvailableHours > 0
|
|
? (totalChargeableHours / totalAvailableHours) * 100
|
|
: 0;
|
|
|
|
return {
|
|
resourceId: resource.id,
|
|
resourceName: resource.displayName,
|
|
chargeabilityTarget: resource.chargeabilityTarget,
|
|
currentChargeability,
|
|
chargeabilityGap: resource.chargeabilityTarget - currentChargeability,
|
|
allocations: activeBookings
|
|
.filter((booking) => ACTIVE_STATUSES.has(booking.status))
|
|
.map((booking) => ({
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
projectName: booking.projectName,
|
|
isChargeable: booking.isChargeable,
|
|
})),
|
|
overallocatedDays,
|
|
underutilizedDays,
|
|
};
|
|
}),
|
|
|
|
findCapacity: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
resourceId: z.string(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
minAvailableHoursPerDay: z.number().optional().default(4),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const resource = await findUniqueOrThrow(
|
|
ctx.db.resource.findUnique({
|
|
where: { id: input.resourceId },
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true } },
|
|
metroCity: { select: { name: true } },
|
|
},
|
|
}),
|
|
"Resource",
|
|
);
|
|
|
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
resourceIds: [resource.id],
|
|
});
|
|
|
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
ctx.db,
|
|
[{
|
|
id: resource.id,
|
|
availability,
|
|
countryId: resource.countryId,
|
|
countryCode: resource.country?.code,
|
|
federalState: resource.federalState,
|
|
metroCityId: resource.metroCityId,
|
|
metroCityName: resource.metroCity?.name,
|
|
}],
|
|
input.startDate,
|
|
input.endDate,
|
|
);
|
|
const context = contexts.get(resource.id);
|
|
const windows: Array<{
|
|
resourceId: string;
|
|
resourceName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
availableHoursPerDay: number;
|
|
availableDays: number;
|
|
totalAvailableHours: number;
|
|
}> = [];
|
|
|
|
let windowStart: Date | null = null;
|
|
let windowAvailableDays = 0;
|
|
let windowTotalHours = 0;
|
|
let windowMinHours = Number.POSITIVE_INFINITY;
|
|
|
|
const closeWindow = (closeDate: Date) => {
|
|
if (windowStart && windowAvailableDays > 0) {
|
|
const previousDay = new Date(closeDate);
|
|
previousDay.setUTCDate(previousDay.getUTCDate() - 1);
|
|
windows.push({
|
|
resourceId: resource.id,
|
|
resourceName: resource.displayName,
|
|
startDate: new Date(windowStart),
|
|
endDate: previousDay,
|
|
availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0,
|
|
availableDays: windowAvailableDays,
|
|
totalAvailableHours: Math.round(windowTotalHours * 10) / 10,
|
|
});
|
|
}
|
|
windowStart = null;
|
|
windowAvailableDays = 0;
|
|
windowTotalHours = 0;
|
|
windowMinHours = Number.POSITIVE_INFINITY;
|
|
};
|
|
|
|
const cursor = new Date(input.startDate);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(input.endDate);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
|
if (availableHoursForDay <= 0) {
|
|
closeWindow(cursor);
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
continue;
|
|
}
|
|
|
|
const { allocatedHours } = calculateAllocatedHoursForDay({
|
|
bookings: resourceBookings.map((booking) => ({
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
status: booking.status,
|
|
})),
|
|
date: cursor,
|
|
context,
|
|
});
|
|
const freeHours = Math.max(0, availableHoursForDay - allocatedHours);
|
|
|
|
if (freeHours >= input.minAvailableHoursPerDay) {
|
|
if (!windowStart) {
|
|
windowStart = new Date(cursor);
|
|
}
|
|
windowAvailableDays += 1;
|
|
windowTotalHours += freeHours;
|
|
windowMinHours = Math.min(windowMinHours, freeHours);
|
|
} else {
|
|
closeWindow(cursor);
|
|
}
|
|
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
closeWindow(new Date(end.getTime() + 86_400_000));
|
|
|
|
return windows;
|
|
}),
|
|
};
|