1383 lines
47 KiB
TypeScript
1383 lines
47 KiB
TypeScript
import { rankResources } from "@capakraken/staffing";
|
|
import { listAssignmentBookings } from "@capakraken/application";
|
|
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import {
|
|
calculateEffectiveAvailableHours,
|
|
calculateEffectiveBookedHours,
|
|
countEffectiveWorkingDays,
|
|
loadResourceDailyAvailabilityContexts,
|
|
type ResourceDailyAvailabilityContext,
|
|
} from "../lib/resource-capacity.js";
|
|
import { fmtEur } from "../lib/format-utils.js";
|
|
import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
|
|
|
|
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
|
"sunday",
|
|
"monday",
|
|
"tuesday",
|
|
"wednesday",
|
|
"thursday",
|
|
"friday",
|
|
"saturday",
|
|
];
|
|
const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
|
|
|
function toIsoDate(value: Date): string {
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function fmtDate(value: Date | null | undefined): string | null {
|
|
return value ? value.toISOString().slice(0, 10) : null;
|
|
}
|
|
|
|
function createUtcDate(year: number, monthIndex: number, day: number): Date {
|
|
return new Date(Date.UTC(year, monthIndex, day));
|
|
}
|
|
|
|
function normalizeUtcDate(value: Date): Date {
|
|
return createUtcDate(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate());
|
|
}
|
|
|
|
function createDateRange(input: {
|
|
startDate?: Date | undefined;
|
|
endDate?: Date | undefined;
|
|
durationDays?: number | undefined;
|
|
}): { startDate: Date; endDate: Date } {
|
|
const startDate = input.startDate
|
|
? normalizeUtcDate(input.startDate)
|
|
: createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
|
|
const endDate = input.endDate
|
|
? normalizeUtcDate(input.endDate)
|
|
: createUtcDate(
|
|
startDate.getUTCFullYear(),
|
|
startDate.getUTCMonth(),
|
|
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
|
|
);
|
|
|
|
if (endDate < startDate) {
|
|
throw new Error("endDate must be on or after startDate.");
|
|
}
|
|
|
|
return { startDate, endDate };
|
|
}
|
|
|
|
function round1(value: number): number {
|
|
return Math.round(value * 10) / 10;
|
|
}
|
|
|
|
function getBaseDayAvailability(
|
|
availability: WeekdayAvailability,
|
|
date: Date,
|
|
): number {
|
|
const key = DAY_KEYS[date.getUTCDay()];
|
|
return key ? (availability[key] ?? 0) : 0;
|
|
}
|
|
|
|
function getEffectiveDayAvailability(
|
|
availability: WeekdayAvailability,
|
|
date: Date,
|
|
context: ResourceDailyAvailabilityContext | undefined,
|
|
): number {
|
|
const key = DAY_KEYS[date.getUTCDay()];
|
|
const baseHours = key ? (availability[key] ?? 0) : 0;
|
|
if (baseHours <= 0) {
|
|
return 0;
|
|
}
|
|
const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0;
|
|
return Math.max(0, baseHours * (1 - fraction));
|
|
}
|
|
|
|
function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean {
|
|
return date >= startDate && date <= endDate;
|
|
}
|
|
|
|
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
|
|
if (workingDays <= 0) {
|
|
return 0;
|
|
}
|
|
return round1(totalHours / workingDays);
|
|
}
|
|
|
|
function createLocationLabel(input: {
|
|
countryCode?: string | null;
|
|
federalState?: string | null;
|
|
metroCityName?: string | null;
|
|
}): string {
|
|
return [
|
|
input.countryCode ?? null,
|
|
input.federalState ?? null,
|
|
input.metroCityName ?? null,
|
|
].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / ");
|
|
}
|
|
|
|
function calculateAllocatedHoursForDay(input: {
|
|
bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>;
|
|
date: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): { allocatedHours: number; chargeableHours: number } {
|
|
const isoDate = toIsoDate(input.date);
|
|
const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0));
|
|
|
|
return input.bookings.reduce(
|
|
(acc, booking) => {
|
|
if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) {
|
|
return acc;
|
|
}
|
|
|
|
const effectiveHours = booking.hoursPerDay * dayFraction;
|
|
acc.allocatedHours += effectiveHours;
|
|
if (booking.isChargeable) {
|
|
acc.chargeableHours += effectiveHours;
|
|
}
|
|
return acc;
|
|
},
|
|
{ allocatedHours: 0, chargeableHours: 0 },
|
|
);
|
|
}
|
|
|
|
type StaffingSuggestionInput = {
|
|
requiredSkills: string[];
|
|
preferredSkills?: string[] | undefined;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
budgetLcrCentsPerHour?: number | undefined;
|
|
chapter?: string | undefined;
|
|
skillCategory?: string | undefined;
|
|
mainSkillsOnly?: boolean | undefined;
|
|
minProficiency?: number | undefined;
|
|
};
|
|
|
|
type StaffingSuggestionsDbClient =
|
|
Parameters<typeof listAssignmentBookings>[0]
|
|
& Parameters<typeof loadResourceDailyAvailabilityContexts>[0]
|
|
& {
|
|
resource: {
|
|
findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
|
|
};
|
|
};
|
|
|
|
type StaffingResourceRecord = {
|
|
id: string;
|
|
displayName: string;
|
|
eid: string;
|
|
fte: number | null;
|
|
chapter: string | null;
|
|
skills: unknown;
|
|
lcrCents: number | null;
|
|
chargeabilityTarget: number | null;
|
|
availability: unknown;
|
|
valueScore: number | null;
|
|
countryId: string | null;
|
|
federalState: string | null;
|
|
metroCityId: string | null;
|
|
country: { code: string; name: string } | null;
|
|
metroCity: { name: string } | null;
|
|
areaRole: { name: string } | null;
|
|
};
|
|
|
|
type BestProjectResourceRankingMode =
|
|
| "lowest_lcr"
|
|
| "highest_remaining_hours_per_day"
|
|
| "highest_remaining_hours";
|
|
|
|
type BestProjectResourceInput = {
|
|
projectId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
minHoursPerDay: number;
|
|
rankingMode: BestProjectResourceRankingMode;
|
|
chapter?: string | undefined;
|
|
roleName?: string | undefined;
|
|
};
|
|
|
|
type BestProjectResourceDbClient =
|
|
Parameters<typeof loadResourceDailyAvailabilityContexts>[0]
|
|
& {
|
|
assignment: {
|
|
findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
|
|
};
|
|
};
|
|
|
|
type BestProjectResourceDetailDbClient = BestProjectResourceDbClient & {
|
|
project: {
|
|
findUnique: (args: Record<string, unknown>) => Promise<unknown>;
|
|
};
|
|
};
|
|
|
|
type BestProjectResourceAssignmentRecord = {
|
|
resourceId: string;
|
|
hoursPerDay: number;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
status: string;
|
|
resource: {
|
|
id: string;
|
|
eid: string;
|
|
displayName: string;
|
|
chapter: string | null;
|
|
lcrCents: number | null;
|
|
availability: unknown;
|
|
countryId: string | null;
|
|
federalState: string | null;
|
|
metroCityId: string | null;
|
|
country: { code: string; name: string } | null;
|
|
metroCity: { name: string } | null;
|
|
areaRole: { name: string } | null;
|
|
};
|
|
};
|
|
|
|
type BestProjectResourceOverlapAssignmentRecord = {
|
|
resourceId: string;
|
|
projectId: string;
|
|
hoursPerDay: number;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
status: string;
|
|
project: { name: string; shortCode: string | null } | null;
|
|
};
|
|
|
|
async function queryStaffingSuggestions(
|
|
db: StaffingSuggestionsDbClient,
|
|
input: StaffingSuggestionInput,
|
|
) {
|
|
const {
|
|
requiredSkills,
|
|
preferredSkills,
|
|
startDate,
|
|
endDate,
|
|
hoursPerDay,
|
|
budgetLcrCentsPerHour,
|
|
chapter,
|
|
skillCategory,
|
|
mainSkillsOnly,
|
|
minProficiency,
|
|
} = input;
|
|
|
|
const resources = await db.resource.findMany({
|
|
where: {
|
|
isActive: true,
|
|
...(chapter ? { chapter } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
eid: true,
|
|
fte: true,
|
|
chapter: true,
|
|
skills: true,
|
|
lcrCents: true,
|
|
chargeabilityTarget: true,
|
|
availability: true,
|
|
valueScore: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true, name: true } },
|
|
metroCity: { select: { name: true } },
|
|
areaRole: { select: { name: true } },
|
|
},
|
|
}) as StaffingResourceRecord[];
|
|
const bookings = await listAssignmentBookings(db, {
|
|
startDate,
|
|
endDate,
|
|
resourceIds: resources.map((resource) => resource.id),
|
|
});
|
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
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,
|
|
})),
|
|
startDate,
|
|
endDate,
|
|
);
|
|
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 enrichedResources = resources.map((resource) => {
|
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
const context = contexts.get(resource.id);
|
|
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
|
|
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
|
|
const baseAvailableHours = calculateEffectiveAvailableHours({
|
|
availability,
|
|
periodStart: startDate,
|
|
periodEnd: endDate,
|
|
context: undefined,
|
|
});
|
|
const totalAvailableHours = calculateEffectiveAvailableHours({
|
|
availability,
|
|
periodStart: startDate,
|
|
periodEnd: endDate,
|
|
context,
|
|
});
|
|
const baseWorkingDays = countEffectiveWorkingDays({
|
|
availability,
|
|
periodStart: startDate,
|
|
periodEnd: endDate,
|
|
context: undefined,
|
|
});
|
|
const effectiveWorkingDays = countEffectiveWorkingDays({
|
|
availability,
|
|
periodStart: startDate,
|
|
periodEnd: endDate,
|
|
context,
|
|
});
|
|
const allocatedHours = activeBookings.reduce(
|
|
(sum, booking) =>
|
|
sum + calculateEffectiveBookedHours({
|
|
availability,
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
periodStart: startDate,
|
|
periodEnd: endDate,
|
|
context,
|
|
}),
|
|
0,
|
|
);
|
|
const holidayDates = [...(context?.holidayDates ?? new Set<string>())].sort();
|
|
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
|
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
|
), 0);
|
|
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
|
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
|
), 0);
|
|
let absenceDayEquivalent = 0;
|
|
let absenceHoursDeduction = 0;
|
|
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
|
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
|
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
|
continue;
|
|
}
|
|
absenceDayEquivalent += fraction;
|
|
absenceHoursDeduction += dayHours * fraction;
|
|
}
|
|
const conflictDays: string[] = [];
|
|
const conflictDetails: Array<{
|
|
date: string;
|
|
baseHours: number;
|
|
effectiveHours: number;
|
|
allocatedHours: number;
|
|
remainingHours: number;
|
|
requestedHours: number;
|
|
shortageHours: number;
|
|
absenceFraction: number;
|
|
isHoliday: boolean;
|
|
}> = [];
|
|
const cursor = new Date(startDate);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const periodEndAtMidnight = new Date(endDate);
|
|
periodEndAtMidnight.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= periodEndAtMidnight) {
|
|
const isoDate = toIsoDate(cursor);
|
|
const baseHoursForDay = getBaseDayAvailability(availability, cursor);
|
|
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
|
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
|
|
const absenceFraction = Math.min(
|
|
1,
|
|
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
|
|
);
|
|
if (availableHoursForDay > 0) {
|
|
const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({
|
|
bookings: activeBookings,
|
|
date: cursor,
|
|
context,
|
|
});
|
|
if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) {
|
|
const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay);
|
|
conflictDays.push(isoDate);
|
|
conflictDetails.push({
|
|
date: isoDate,
|
|
baseHours: round1(baseHoursForDay),
|
|
effectiveHours: round1(availableHoursForDay),
|
|
allocatedHours: round1(allocatedHoursForDay),
|
|
remainingHours: round1(remainingHoursForDay),
|
|
requestedHours: round1(hoursPerDay),
|
|
shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)),
|
|
absenceFraction: round1(absenceFraction),
|
|
isHoliday,
|
|
});
|
|
}
|
|
}
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
const remainingHours = Math.max(0, totalAvailableHours - allocatedHours);
|
|
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays);
|
|
const utilizationPercent =
|
|
totalAvailableHours > 0
|
|
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
|
: 0;
|
|
|
|
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
|
let skills = resource.skills as unknown as SkillRow[];
|
|
|
|
if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill);
|
|
if (skillCategory) skills = skills.filter((s) => s.category === skillCategory);
|
|
if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency);
|
|
|
|
return {
|
|
id: resource.id,
|
|
displayName: resource.displayName,
|
|
eid: resource.eid,
|
|
fte: resource.fte,
|
|
chapter: resource.chapter,
|
|
role: resource.areaRole?.name ?? null,
|
|
skills: skills as unknown as import("@capakraken/shared").SkillEntry[],
|
|
lcrCents: resource.lcrCents,
|
|
chargeabilityTarget: resource.chargeabilityTarget,
|
|
currentUtilizationPercent: utilizationPercent,
|
|
hasAvailabilityConflicts: conflictDays.length > 0,
|
|
conflictDays,
|
|
valueScore: resource.valueScore ?? 0,
|
|
transparency: {
|
|
location: {
|
|
countryCode: resource.country?.code ?? null,
|
|
countryName: resource.country?.name ?? null,
|
|
federalState: resource.federalState ?? null,
|
|
metroCityName: resource.metroCity?.name ?? null,
|
|
label: createLocationLabel({
|
|
countryCode: resource.country?.code ?? null,
|
|
federalState: resource.federalState,
|
|
metroCityName: resource.metroCity?.name ?? null,
|
|
}),
|
|
},
|
|
capacity: {
|
|
requestedHoursPerDay: round1(hoursPerDay),
|
|
requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay),
|
|
baseWorkingDays: round1(baseWorkingDays),
|
|
effectiveWorkingDays: round1(effectiveWorkingDays),
|
|
baseAvailableHours: round1(baseAvailableHours),
|
|
effectiveAvailableHours: round1(totalAvailableHours),
|
|
bookedHours: round1(allocatedHours),
|
|
remainingHours: round1(remainingHours),
|
|
remainingHoursPerDay,
|
|
holidayCount: holidayDates.length,
|
|
holidayWorkdayCount,
|
|
holidayHoursDeduction: round1(holidayHoursDeduction),
|
|
absenceDayEquivalent: round1(absenceDayEquivalent),
|
|
absenceHoursDeduction: round1(absenceHoursDeduction),
|
|
},
|
|
conflicts: {
|
|
count: conflictDays.length,
|
|
conflictDays,
|
|
details: conflictDetails,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const ranked = rankResources({
|
|
requiredSkills,
|
|
preferredSkills,
|
|
resources: enrichedResources,
|
|
budgetLcrCentsPerHour,
|
|
} as unknown as Parameters<typeof rankResources>[0]);
|
|
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
|
|
|
|
return [...ranked].sort((a, b) => {
|
|
if (Math.abs(a.score - b.score) <= 2) {
|
|
const aVal = (enrichedResources.find((resource) => resource.id === a.resourceId)?.valueScore ?? 0);
|
|
const bVal = (enrichedResources.find((resource) => resource.id === b.resourceId)?.valueScore ?? 0);
|
|
return bVal - aVal;
|
|
}
|
|
return 0;
|
|
}).map((suggestion, index) => {
|
|
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
|
|
const fallbackBreakdown = "breakdown" in suggestion
|
|
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
|
|
: undefined;
|
|
const scoreBreakdown = suggestion.scoreBreakdown ?? {
|
|
skillScore: fallbackBreakdown?.skillScore ?? 0,
|
|
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
|
|
costScore: fallbackBreakdown?.costScore ?? 0,
|
|
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
|
|
total: suggestion.score,
|
|
};
|
|
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
|
|
const tieBreakerApplied = baseRank !== index + 1;
|
|
|
|
return {
|
|
...suggestion,
|
|
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
|
|
eid: suggestion.eid ?? resource?.eid ?? "",
|
|
fte: resource?.fte ?? 0,
|
|
chapter: resource?.chapter ?? null,
|
|
role: resource?.role ?? null,
|
|
scoreBreakdown,
|
|
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
|
|
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
|
),
|
|
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
|
|
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
|
),
|
|
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
|
|
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
|
|
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
|
|
valueScore: resource?.valueScore ?? 0,
|
|
location: resource?.transparency.location ?? {
|
|
countryCode: null,
|
|
countryName: null,
|
|
federalState: null,
|
|
metroCityName: null,
|
|
label: "",
|
|
},
|
|
capacity: resource?.transparency.capacity ?? {
|
|
requestedHoursPerDay: round1(hoursPerDay),
|
|
requestedHoursTotal: 0,
|
|
baseWorkingDays: 0,
|
|
effectiveWorkingDays: 0,
|
|
baseAvailableHours: 0,
|
|
effectiveAvailableHours: 0,
|
|
bookedHours: 0,
|
|
remainingHours: 0,
|
|
remainingHoursPerDay: 0,
|
|
holidayCount: 0,
|
|
holidayWorkdayCount: 0,
|
|
holidayHoursDeduction: 0,
|
|
absenceDayEquivalent: 0,
|
|
absenceHoursDeduction: 0,
|
|
},
|
|
conflicts: resource?.transparency.conflicts ?? {
|
|
count: 0,
|
|
conflictDays: [],
|
|
details: [],
|
|
},
|
|
ranking: {
|
|
rank: index + 1,
|
|
baseRank,
|
|
tieBreakerApplied,
|
|
tieBreakerReason: tieBreakerApplied
|
|
? "Within 2 score points, higher value score moves the candidate up."
|
|
: null,
|
|
model: "Composite ranking across skill fit, availability, cost, and utilization.",
|
|
components: [
|
|
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
|
|
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
|
|
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
|
|
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
|
|
],
|
|
},
|
|
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
|
|
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
|
|
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
|
|
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
|
|
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function queryBestProjectResource(
|
|
db: BestProjectResourceDbClient,
|
|
input: BestProjectResourceInput,
|
|
) {
|
|
const projectAssignmentsResult = await db.assignment.findMany({
|
|
where: {
|
|
projectId: input.projectId,
|
|
status: { not: "CANCELLED" },
|
|
startDate: { lte: input.endDate },
|
|
endDate: { gte: input.startDate },
|
|
resource: {
|
|
isActive: true,
|
|
...(input.chapter ? { chapter: { contains: input.chapter, mode: "insensitive" } } : {}),
|
|
...(input.roleName ? { areaRole: { name: { contains: input.roleName, mode: "insensitive" } } } : {}),
|
|
},
|
|
},
|
|
select: {
|
|
resourceId: true,
|
|
hoursPerDay: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
status: true,
|
|
resource: {
|
|
select: {
|
|
id: true,
|
|
eid: true,
|
|
displayName: true,
|
|
chapter: true,
|
|
lcrCents: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true, name: true } },
|
|
metroCity: { select: { name: true } },
|
|
areaRole: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ resourceId: "asc" }, { startDate: "asc" }],
|
|
});
|
|
const projectAssignments = (Array.isArray(projectAssignmentsResult)
|
|
? projectAssignmentsResult
|
|
: []) as BestProjectResourceAssignmentRecord[];
|
|
|
|
if (projectAssignments.length === 0) {
|
|
return {
|
|
period: {
|
|
startDate: toIsoDate(input.startDate),
|
|
endDate: toIsoDate(input.endDate),
|
|
minHoursPerDay: input.minHoursPerDay,
|
|
rankingMode: input.rankingMode,
|
|
},
|
|
filters: {
|
|
chapter: input.chapter ?? null,
|
|
roleName: input.roleName ?? null,
|
|
},
|
|
candidateCount: 0,
|
|
candidates: [],
|
|
bestMatch: null,
|
|
note: "No active project resources matched the requested filters in the selected period.",
|
|
};
|
|
}
|
|
|
|
const resourcesById = new Map<string, (typeof projectAssignments)[number]["resource"]>();
|
|
const assignmentsOnProjectByResourceId = new Map<string, typeof projectAssignments>();
|
|
for (const assignment of projectAssignments) {
|
|
resourcesById.set(assignment.resourceId, assignment.resource);
|
|
const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? [];
|
|
items.push(assignment);
|
|
assignmentsOnProjectByResourceId.set(assignment.resourceId, items);
|
|
}
|
|
|
|
const resourceIds = [...resourcesById.keys()];
|
|
const overlappingAssignmentsResult = await db.assignment.findMany({
|
|
where: {
|
|
resourceId: { in: resourceIds },
|
|
status: { not: "CANCELLED" },
|
|
startDate: { lte: input.endDate },
|
|
endDate: { gte: input.startDate },
|
|
},
|
|
select: {
|
|
resourceId: true,
|
|
projectId: true,
|
|
hoursPerDay: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
status: true,
|
|
project: { select: { name: true, shortCode: true } },
|
|
},
|
|
orderBy: [{ resourceId: "asc" }, { startDate: "asc" }],
|
|
});
|
|
const overlappingAssignments = (Array.isArray(overlappingAssignmentsResult)
|
|
? overlappingAssignmentsResult
|
|
: []) as BestProjectResourceOverlapAssignmentRecord[];
|
|
|
|
const assignmentsByResourceId = new Map<string, typeof overlappingAssignments>();
|
|
for (const assignment of overlappingAssignments) {
|
|
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
|
items.push(assignment);
|
|
assignmentsByResourceId.set(assignment.resourceId, items);
|
|
}
|
|
|
|
const resources = [...resourcesById.values()];
|
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
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 candidates = resources.map((resource) => {
|
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
const context = contexts.get(resource.id);
|
|
const baseWorkingDays = countEffectiveWorkingDays({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context: undefined,
|
|
});
|
|
const workingDays = countEffectiveWorkingDays({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
});
|
|
const baseAvailableHours = calculateEffectiveAvailableHours({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context: undefined,
|
|
});
|
|
const availableHours = calculateEffectiveAvailableHours({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
});
|
|
const assignments = assignmentsByResourceId.get(resource.id) ?? [];
|
|
const bookedHours = assignments.reduce(
|
|
(sum, assignment) =>
|
|
sum + calculateEffectiveBookedHours({
|
|
availability,
|
|
startDate: assignment.startDate,
|
|
endDate: assignment.endDate,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
}),
|
|
0,
|
|
);
|
|
const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce(
|
|
(sum, assignment) =>
|
|
sum + calculateEffectiveBookedHours({
|
|
availability,
|
|
startDate: assignment.startDate,
|
|
endDate: assignment.endDate,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
}),
|
|
0,
|
|
);
|
|
let excludedCapacityDays = 0;
|
|
for (const fraction of context?.absenceFractionsByDate.values() ?? []) {
|
|
excludedCapacityDays += fraction;
|
|
}
|
|
const holidayWorkdayCount = [...(context?.holidayDates ?? new Set<string>())].reduce((count, isoDate) => (
|
|
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
|
), 0);
|
|
const holidayHoursDeduction = [...(context?.holidayDates ?? new Set<string>())].reduce((sum, isoDate) => (
|
|
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
|
), 0);
|
|
let absenceDayEquivalent = 0;
|
|
let absenceHoursDeduction = 0;
|
|
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
|
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
|
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
|
continue;
|
|
}
|
|
absenceDayEquivalent += fraction;
|
|
absenceHoursDeduction += dayHours * fraction;
|
|
}
|
|
|
|
const remainingHours = Math.max(0, availableHours - bookedHours);
|
|
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays);
|
|
|
|
return {
|
|
id: resource.id,
|
|
eid: resource.eid,
|
|
name: resource.displayName,
|
|
role: resource.areaRole?.name ?? null,
|
|
chapter: resource.chapter ?? null,
|
|
country: resource.country?.name ?? resource.country?.code ?? null,
|
|
countryCode: resource.country?.code ?? null,
|
|
federalState: resource.federalState ?? null,
|
|
metroCity: resource.metroCity?.name ?? null,
|
|
lcrCents: resource.lcrCents ?? null,
|
|
lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
|
|
baseWorkingDays: round1(baseWorkingDays),
|
|
workingDays,
|
|
excludedCapacityDays: round1(excludedCapacityDays),
|
|
baseAvailableHours: round1(baseAvailableHours),
|
|
availableHours: round1(availableHours),
|
|
bookedHours: round1(bookedHours),
|
|
remainingHours: round1(remainingHours),
|
|
remainingHoursPerDay,
|
|
projectHours: round1(projectHours),
|
|
assignmentCount: assignments.length,
|
|
holidaySummary: {
|
|
count: context?.holidayDates.size ?? 0,
|
|
workdayCount: holidayWorkdayCount,
|
|
hoursDeduction: round1(holidayHoursDeduction),
|
|
holidayDates: [...(context?.holidayDates ?? new Set<string>())].sort(),
|
|
},
|
|
absenceSummary: {
|
|
dayEquivalent: round1(absenceDayEquivalent),
|
|
hoursDeduction: round1(absenceHoursDeduction),
|
|
},
|
|
capacityBreakdown: {
|
|
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
|
baseAvailableHours: round1(baseAvailableHours),
|
|
holidayHoursDeduction: round1(holidayHoursDeduction),
|
|
absenceHoursDeduction: round1(absenceHoursDeduction),
|
|
availableHours: round1(availableHours),
|
|
},
|
|
};
|
|
}).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay);
|
|
|
|
candidates.sort((left, right) => {
|
|
if (input.rankingMode === "highest_remaining_hours_per_day") {
|
|
return right.remainingHoursPerDay - left.remainingHoursPerDay
|
|
|| right.remainingHours - left.remainingHours
|
|
|| (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER);
|
|
}
|
|
if (input.rankingMode === "highest_remaining_hours") {
|
|
return right.remainingHours - left.remainingHours
|
|
|| right.remainingHoursPerDay - left.remainingHoursPerDay
|
|
|| (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER);
|
|
}
|
|
return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER)
|
|
|| right.remainingHoursPerDay - left.remainingHoursPerDay
|
|
|| right.remainingHours - left.remainingHours;
|
|
});
|
|
|
|
return {
|
|
period: {
|
|
startDate: toIsoDate(input.startDate),
|
|
endDate: toIsoDate(input.endDate),
|
|
minHoursPerDay: input.minHoursPerDay,
|
|
rankingMode: input.rankingMode,
|
|
},
|
|
filters: {
|
|
chapter: input.chapter ?? null,
|
|
roleName: input.roleName ?? null,
|
|
},
|
|
candidateCount: candidates.length,
|
|
bestMatch: candidates[0] ?? null,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
export const staffingRouter = createTRPCRouter({
|
|
/**
|
|
* Get ranked resource suggestions for a staffing requirement.
|
|
*/
|
|
getSuggestions: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
requiredSkills: z.array(z.string()),
|
|
preferredSkills: z.array(z.string()).optional(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
hoursPerDay: z.number().min(0).max(24),
|
|
budgetLcrCentsPerHour: z.number().optional(),
|
|
chapter: z.string().optional(),
|
|
skillCategory: z.string().optional(),
|
|
mainSkillsOnly: z.boolean().optional(),
|
|
minProficiency: z.number().min(1).max(5).optional(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
return queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input);
|
|
}),
|
|
|
|
getProjectStaffingSuggestions: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string().min(1),
|
|
roleName: z.string().optional(),
|
|
startDate: z.coerce.date().optional(),
|
|
endDate: z.coerce.date().optional(),
|
|
limit: z.number().int().min(1).max(50).optional().default(5),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: {
|
|
id: true,
|
|
shortCode: true,
|
|
name: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
},
|
|
}), "Project");
|
|
const startDate = input.startDate ?? project.startDate ?? new Date();
|
|
const endDate = input.endDate ?? project.endDate ?? new Date();
|
|
const normalizedRoleFilter = input.roleName?.trim().toLowerCase();
|
|
const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, {
|
|
requiredSkills: [],
|
|
startDate,
|
|
endDate,
|
|
hoursPerDay: 8,
|
|
});
|
|
|
|
return {
|
|
project: `${project.name} (${project.shortCode})`,
|
|
period: `${fmtDate(startDate)} to ${fmtDate(endDate)}`,
|
|
suggestions: suggestions
|
|
.filter((suggestion) => {
|
|
if (!normalizedRoleFilter) {
|
|
return true;
|
|
}
|
|
return suggestion.role?.toLowerCase().includes(normalizedRoleFilter) ?? false;
|
|
})
|
|
.map((suggestion) => ({
|
|
id: suggestion.resourceId,
|
|
name: suggestion.resourceName,
|
|
eid: suggestion.eid,
|
|
role: suggestion.role,
|
|
chapter: suggestion.chapter,
|
|
fte: round1(suggestion.fte ?? 0),
|
|
lcr: fmtEur(Math.round((suggestion.estimatedDailyCostCents ?? 0) / 8)),
|
|
workingDays: round1(suggestion.capacity.effectiveWorkingDays),
|
|
availableHours: round1(suggestion.capacity.remainingHours),
|
|
bookedHours: round1(suggestion.capacity.bookedHours),
|
|
availableHoursPerDay: round1(suggestion.capacity.remainingHoursPerDay),
|
|
utilization: round1(suggestion.currentUtilization ?? 0),
|
|
}))
|
|
.filter((suggestion) => suggestion.availableHours > 0)
|
|
.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;
|
|
}),
|
|
|
|
findBestProjectResource: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
minHoursPerDay: z.number().min(0).default(3),
|
|
rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"),
|
|
chapter: z.string().optional(),
|
|
roleName: z.string().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input);
|
|
}),
|
|
|
|
getBestProjectResourceDetail: planningReadProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string().min(1),
|
|
startDate: z.coerce.date().optional(),
|
|
endDate: z.coerce.date().optional(),
|
|
durationDays: z.number().int().min(1).optional(),
|
|
minHoursPerDay: z.number().min(0).default(3),
|
|
rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"),
|
|
chapter: z.string().optional(),
|
|
roleName: z.string().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
shortCode: true,
|
|
status: true,
|
|
responsiblePerson: true,
|
|
},
|
|
}), "Project");
|
|
const { startDate, endDate } = createDateRange({
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
durationDays: input.durationDays,
|
|
});
|
|
const result = await queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, {
|
|
projectId: input.projectId,
|
|
startDate,
|
|
endDate,
|
|
minHoursPerDay: input.minHoursPerDay,
|
|
rankingMode: input.rankingMode,
|
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
|
...(input.roleName ? { roleName: input.roleName } : {}),
|
|
});
|
|
|
|
return {
|
|
...result,
|
|
project: {
|
|
id: project.id,
|
|
name: project.name,
|
|
shortCode: project.shortCode,
|
|
status: project.status,
|
|
responsiblePerson: project.responsiblePerson,
|
|
},
|
|
};
|
|
}),
|
|
});
|