Files
Nexus/packages/api/src/router/staffing-suggestions-read.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

491 lines
18 KiB
TypeScript

import { rankResources } from "@nexus/staffing";
import { listAssignmentBookings } from "@nexus/application";
import { PermissionKey, toIsoDateOrNull } from "@nexus/shared";
import type { SkillEntry, WeekdayAvailability } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
import { planningReadProcedure, requirePermission } from "../trpc.js";
import {
ACTIVE_STATUSES,
calculateAllocatedHoursForDay,
createLocationLabel,
getBaseDayAvailability,
getEffectiveDayAvailability,
round1,
toIsoDate,
} from "./staffing-shared.js";
import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js";
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 capacity = buildResourceCapacitySummary({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
bookings: activeBookings,
});
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 allocatedHours = capacity.bookedHours;
const remainingHoursPerDay = capacity.remainingHoursPerDay;
const utilizationPercent =
capacity.availableHours > 0
? Math.min(100, (allocatedHours / capacity.availableHours) * 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 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(capacity.workingDays * hoursPerDay),
baseWorkingDays: capacity.baseWorkingDays,
effectiveWorkingDays: capacity.workingDays,
baseAvailableHours: capacity.baseAvailableHours,
effectiveAvailableHours: capacity.availableHours,
bookedHours: capacity.bookedHours,
remainingHours: capacity.remainingHours,
remainingHoursPerDay,
holidayCount: capacity.holidaySummary.count,
holidayWorkdayCount: capacity.holidaySummary.workdayCount,
holidayHoursDeduction: capacity.holidaySummary.hoursDeduction,
absenceDayEquivalent: capacity.absenceSummary.dayEquivalent,
absenceHoursDeduction: capacity.absenceSummary.hoursDeduction,
},
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,
};
});
}
const GetProjectStaffingSuggestionsInputSchema = z.object({
projectId: z.string().min(1).max(64),
roleName: z.string().max(200).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.number().int().min(1).max(50).optional().default(5),
});
export const staffingSuggestionsReadProcedures = {
getSuggestions: planningReadProcedure
.input(
z.object({
requiredSkills: z.array(z.string().max(200)).max(200),
preferredSkills: z.array(z.string().max(200)).max(200).optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
budgetLcrCentsPerHour: z.number().int().min(0).max(1_000_000_00).optional(),
chapter: z.string().max(100).optional(),
skillCategory: z.string().max(100).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(
GetProjectStaffingSuggestionsInputSchema as z.ZodType<
z.infer<typeof GetProjectStaffingSuggestionsInputSchema>,
z.ZodTypeDef,
z.input<typeof GetProjectStaffingSuggestionsInputSchema>
>,
)
.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: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(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),
};
}),
};