265 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
}),
|
|
};
|