refactor(api): extract staffing capacity read procedures
This commit is contained in:
@@ -0,0 +1,385 @@
|
|||||||
|
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;
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { fmtEur } from "../lib/format-utils.js";
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
|
||||||
import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js";
|
import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js";
|
||||||
|
import { staffingCapacityReadProcedures } from "./staffing-capacity-read.js";
|
||||||
import {
|
import {
|
||||||
ACTIVE_STATUSES,
|
ACTIVE_STATUSES,
|
||||||
averagePerWorkingDay,
|
averagePerWorkingDay,
|
||||||
@@ -497,376 +498,6 @@ export const staffingRouter = createTRPCRouter({
|
|||||||
.slice(0, input.limit),
|
.slice(0, input.limit),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
...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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze utilization for a specific resource over a date range.
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find capacity windows for a resource.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}),
|
|
||||||
|
|
||||||
...staffingBestProjectResourceProcedures,
|
...staffingBestProjectResourceProcedures,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user