Files
CapaKraken/packages/api/src/router/allocation-availability.ts
T

232 lines
7.3 KiB
TypeScript

import type { WeekdayAvailability } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { averagePerWorkingDay, round1, toIsoDate } from "./allocation-shared.js";
export async function buildResourceAvailabilityView(
db: Pick<import("@capakraken/db").PrismaClient, "resource" | "assignment" | "vacation" | "holidayCalendar">,
input: {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
},
) {
const resource = await db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true, displayName: true, eid: true, fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { dailyWorkingHours: true, code: true } },
metroCity: { select: { name: true } },
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const availability = (resource.availability as WeekdayAvailability | null) ?? {
monday: fallbackDailyHours,
tuesday: fallbackDailyHours,
wednesday: fallbackDailyHours,
thursday: fallbackDailyHours,
friday: fallbackDailyHours,
saturday: 0,
sunday: 0,
};
const [existingAssignments, vacations] = await Promise.all([
db.assignment.findMany({
where: {
resourceId: input.resourceId,
status: { not: "CANCELLED" },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: {
id: true, startDate: true, endDate: true, hoursPerDay: true, status: true,
project: { select: { name: true, shortCode: true } },
},
orderBy: { startDate: "asc" },
}),
db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: {
id: true,
type: true,
startDate: true,
endDate: true,
isHalfDay: true,
halfDayPart: true,
status: true,
},
orderBy: { startDate: "asc" },
}),
]);
const contexts = await loadResourceDailyAvailabilityContexts(
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 totalWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
let availableDays = 0;
let conflictDays = 0;
let partialDays = 0;
let totalAvailableHours = 0;
const requestedHpd = input.hoursPerDay;
const currentDate = new Date(input.startDate);
const endDate = new Date(input.endDate);
while (currentDate <= endDate) {
const effectiveDayCapacity = calculateEffectiveDayAvailability({
availability,
date: currentDate,
context,
});
if (effectiveDayCapacity > 0) {
let bookedHours = 0;
for (const assignment of existingAssignments) {
bookedHours += calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: currentDate,
periodEnd: currentDate,
context,
});
}
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
if (remainingCapacity >= requestedHpd) {
availableDays++;
totalAvailableHours += requestedHpd;
} else if (remainingCapacity > 0) {
partialDays++;
totalAvailableHours += remainingCapacity;
} else {
conflictDays++;
}
}
currentDate.setDate(currentDate.getDate() + 1);
}
const totalRequestedHours = totalWorkingDays * requestedHpd;
const totalPeriodCapacity = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const dailyCapacity = totalWorkingDays > 0
? round1(totalPeriodCapacity / totalWorkingDays)
: 0;
return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
dailyCapacity,
totalWorkingDays,
availableDays,
partialDays,
conflictDays,
totalAvailableHours: round1(totalAvailableHours),
totalRequestedHours,
coveragePercent: totalRequestedHours > 0
? Math.round((totalAvailableHours / totalRequestedHours) * 100)
: 0,
existingAssignments: existingAssignments.map((assignment) => ({
project: assignment.project.name,
code: assignment.project.shortCode,
hoursPerDay: assignment.hoursPerDay,
start: toIsoDate(assignment.startDate),
end: toIsoDate(assignment.endDate),
status: assignment.status,
})),
vacations: vacations.map((vacation) => ({
id: vacation.id,
type: vacation.type,
status: vacation.status,
start: toIsoDate(vacation.startDate),
end: toIsoDate(vacation.endDate),
isHalfDay: vacation.isHalfDay,
halfDayPart: vacation.halfDayPart,
})),
};
}
export function buildResourceAvailabilitySummary(
availability: Awaited<ReturnType<typeof buildResourceAvailabilityView>>,
period: { startDate: Date; endDate: Date },
) {
const periodAvailableHours = availability.totalRequestedHours > 0
? round1(availability.dailyCapacity * availability.totalWorkingDays)
: 0;
const periodRemainingHours = round1(availability.totalAvailableHours);
const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours));
return {
resource: availability.resource.name,
period: `${toIsoDate(period.startDate)} to ${toIsoDate(period.endDate)}`,
fte: null,
workingDays: availability.totalWorkingDays,
periodAvailableHours,
periodBookedHours,
periodRemainingHours,
maxHoursPerDay: availability.dailyCapacity,
currentBookedHoursPerDay: round1(
Math.max(
0,
availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1),
),
),
availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays),
isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0,
existingAllocations: availability.existingAssignments.map((assignment) => ({
project: `${assignment.project} (${assignment.code})`,
hoursPerDay: assignment.hoursPerDay,
status: assignment.status,
start: assignment.start,
end: assignment.end,
})),
vacations: availability.vacations.map((vacation) => ({
type: vacation.type,
start: vacation.start,
end: vacation.end,
isHalfDay: vacation.isHalfDay,
})),
};
}