348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
|
|
import { fmtEur } from "../lib/format-utils.js";
|
|
import { planningReadProcedure, requirePermission } from "../trpc.js";
|
|
import { createDateRange, round1, toIsoDate } from "./staffing-shared.js";
|
|
import { buildResourceCapacitySummary } from "./staffing-capacity-summary.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 assignments = assignmentsByResourceId.get(resource.id) ?? [];
|
|
const capacity = buildResourceCapacitySummary({
|
|
availability,
|
|
periodStart: input.startDate,
|
|
periodEnd: input.endDate,
|
|
context,
|
|
bookings: assignments,
|
|
});
|
|
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,
|
|
);
|
|
|
|
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: capacity.baseWorkingDays,
|
|
workingDays: capacity.workingDays,
|
|
excludedCapacityDays: capacity.excludedCapacityDays,
|
|
baseAvailableHours: capacity.baseAvailableHours,
|
|
availableHours: capacity.availableHours,
|
|
bookedHours: capacity.bookedHours,
|
|
remainingHours: capacity.remainingHours,
|
|
remainingHoursPerDay: capacity.remainingHoursPerDay,
|
|
projectHours: round1(projectHours),
|
|
assignmentCount: assignments.length,
|
|
holidaySummary: {
|
|
count: capacity.holidaySummary.count,
|
|
workdayCount: capacity.holidaySummary.workdayCount,
|
|
hoursDeduction: capacity.holidaySummary.hoursDeduction,
|
|
holidayDates: capacity.holidaySummary.holidayDates,
|
|
},
|
|
absenceSummary: {
|
|
dayEquivalent: capacity.absenceSummary.dayEquivalent,
|
|
hoursDeduction: capacity.absenceSummary.hoursDeduction,
|
|
},
|
|
capacityBreakdown: capacity.capacityBreakdown,
|
|
};
|
|
}).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,
|
|
},
|
|
};
|
|
}),
|
|
};
|