refactor(api): extract staffing best-project procedures

This commit is contained in:
2026-03-31 08:45:20 +02:00
parent 155625f467
commit cc9cc22c9b
3 changed files with 551 additions and 522 deletions
@@ -0,0 +1,416 @@
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 {
averagePerWorkingDay,
createDateRange,
getBaseDayAvailability,
round1,
toIsoDate,
} from "./staffing-shared.js";
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 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 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 staffingBestProjectResourceProcedures = {
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,
},
};
}),
};