refactor(api): extract staffing suggestions read procedures

This commit is contained in:
2026-03-31 11:26:45 +02:00
parent 77b21462d8
commit be597dc1c5
2 changed files with 498 additions and 496 deletions
+3 -496
View File
@@ -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,
});