refactor(api): extract staffing suggestions read procedures
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
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,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import { planningReadProcedure, requirePermission } from "../trpc.js";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
averagePerWorkingDay,
|
||||
calculateAllocatedHoursForDay,
|
||||
createLocationLabel,
|
||||
getBaseDayAvailability,
|
||||
getEffectiveDayAvailability,
|
||||
round1,
|
||||
toIsoDate,
|
||||
} from "./staffing-shared.js";
|
||||
|
||||
function fmtDate(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
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[]>;
|
||||
};
|
||||
project: {
|
||||
findUnique: (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;
|
||||
};
|
||||
|
||||
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((skill) => skill.isMainSkill);
|
||||
}
|
||||
if (skillCategory) {
|
||||
skills = skills.filter((skill) => skill.category === skillCategory);
|
||||
}
|
||||
if (minProficiency) {
|
||||
skills = skills.filter((skill) => skill.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((left, right) => {
|
||||
if (Math.abs(left.score - right.score) <= 2) {
|
||||
const leftValue = enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
|
||||
const rightValue = enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
|
||||
return rightValue - leftValue;
|
||||
}
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
export const staffingSuggestionsReadProcedures = {
|
||||
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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -1,503 +1,10 @@
|
||||
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,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
|
||||
import { createTRPCRouter } from "../trpc.js";
|
||||
import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js";
|
||||
import { staffingCapacityReadProcedures } from "./staffing-capacity-read.js";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
averagePerWorkingDay,
|
||||
calculateAllocatedHoursForDay,
|
||||
createLocationLabel,
|
||||
getBaseDayAvailability,
|
||||
getEffectiveDayAvailability,
|
||||
round1,
|
||||
toIsoDate,
|
||||
} from "./staffing-shared.js";
|
||||
|
||||
function fmtDate(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
import { staffingSuggestionsReadProcedures } from "./staffing-suggestions-read.js";
|
||||
|
||||
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),
|
||||
};
|
||||
}),
|
||||
...staffingSuggestionsReadProcedures,
|
||||
...staffingCapacityReadProcedures,
|
||||
...staffingBestProjectResourceProcedures,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user