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 { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
|
||||
import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js";
|
||||
import { staffingCapacityReadProcedures } from "./staffing-capacity-read.js";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
averagePerWorkingDay,
|
||||
@@ -497,376 +498,6 @@ export const staffingRouter = createTRPCRouter({
|
||||
.slice(0, input.limit),
|
||||
};
|
||||
}),
|
||||
|
||||
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;
|
||||
}),
|
||||
|
||||
...staffingCapacityReadProcedures,
|
||||
...staffingBestProjectResourceProcedures,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user