refactor(api): extract staffing best-project procedures
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { type WeekdayAvailability } from "@capakraken/shared";
|
||||
import { type ResourceDailyAvailabilityContext } from "../lib/resource-capacity.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
export const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
|
||||
export function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function getBaseDayAvailability(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function averagePerWorkingDay(totalHours: number, workingDays: number): number {
|
||||
if (workingDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return round1(totalHours / workingDays);
|
||||
}
|
||||
|
||||
export 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(" / ");
|
||||
}
|
||||
|
||||
export 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 },
|
||||
);
|
||||
}
|
||||
@@ -8,135 +8,25 @@ import {
|
||||
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);
|
||||
}
|
||||
import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -178,67 +68,6 @@ type StaffingResourceRecord = {
|
||||
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,
|
||||
@@ -584,278 +413,6 @@ async function queryStaffingSuggestions(
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1311,72 +868,5 @@ export const staffingRouter = createTRPCRouter({
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
...staffingBestProjectResourceProcedures,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user