201 lines
7.0 KiB
TypeScript
201 lines
7.0 KiB
TypeScript
import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarchy/staffing";
|
|
import { listAssignmentBookings } from "@planarchy/application";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
|
|
export const staffingRouter = createTRPCRouter({
|
|
/**
|
|
* Get ranked resource suggestions for a staffing requirement.
|
|
*/
|
|
getSuggestions: protectedProcedure
|
|
.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 }) => {
|
|
const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input;
|
|
|
|
const resources = await ctx.db.resource.findMany({
|
|
where: {
|
|
isActive: true,
|
|
...(chapter ? { chapter } : {}),
|
|
},
|
|
});
|
|
const bookings = await listAssignmentBookings(ctx.db, {
|
|
startDate,
|
|
endDate,
|
|
resourceIds: resources.map((resource) => resource.id),
|
|
});
|
|
|
|
// Compute utilization percent for each resource in the requested period
|
|
const enrichedResources = resources.map((resource) => {
|
|
const totalAvailableHours =
|
|
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
|
|
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
|
|
|
const allocatedHoursPerDay = resourceBookings.reduce(
|
|
(sum, a) => sum + a.hoursPerDay,
|
|
0,
|
|
);
|
|
|
|
const utilizationPercent =
|
|
totalAvailableHours > 0
|
|
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
|
: 0;
|
|
|
|
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
|
|
|
|
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
|
let skills = resource.skills as unknown as SkillRow[];
|
|
|
|
// Apply skill filters before matching
|
|
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,
|
|
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
|
|
lcrCents: resource.lcrCents,
|
|
chargeabilityTarget: resource.chargeabilityTarget,
|
|
currentUtilizationPercent: utilizationPercent,
|
|
hasAvailabilityConflicts: wouldExceedCapacity,
|
|
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
|
|
valueScore: resource.valueScore ?? 0,
|
|
};
|
|
});
|
|
|
|
const ranked = rankResources({
|
|
requiredSkills,
|
|
preferredSkills: preferredSkills,
|
|
resources: enrichedResources,
|
|
budgetLcrCentsPerHour,
|
|
} as unknown as Parameters<typeof rankResources>[0]);
|
|
|
|
// Value-score tiebreaker: within 2 points, prefer higher valueScore
|
|
return ranked.sort((a, b) => {
|
|
if (Math.abs(a.score - b.score) <= 2) {
|
|
const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0);
|
|
const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0);
|
|
return bVal - aVal;
|
|
}
|
|
return 0;
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* Analyze utilization for a specific resource over a date range.
|
|
*/
|
|
analyzeUtilization: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
resourceId: z.string(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const resource = await ctx.db.resource.findUnique({
|
|
where: { id: input.resourceId },
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
chargeabilityTarget: true,
|
|
availability: true,
|
|
},
|
|
});
|
|
|
|
if (!resource) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
|
}
|
|
|
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
resourceIds: [resource.id],
|
|
});
|
|
|
|
return analyzeUtilization({
|
|
resource: {
|
|
id: resource.id,
|
|
displayName: resource.displayName,
|
|
chargeabilityTarget: resource.chargeabilityTarget,
|
|
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
|
|
},
|
|
allocations: resourceBookings.map((booking) => ({
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
status: booking.status,
|
|
projectName: booking.project.name,
|
|
isChargeable: booking.project.orderType === "CHARGEABLE",
|
|
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
|
|
analysisStart: input.startDate,
|
|
analysisEnd: input.endDate,
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* Find capacity windows for a resource.
|
|
*/
|
|
findCapacity: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
resourceId: z.string(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
minAvailableHoursPerDay: z.number().optional().default(4),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const resource = await ctx.db.resource.findUnique({
|
|
where: { id: input.resourceId },
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
availability: true,
|
|
},
|
|
});
|
|
|
|
if (!resource) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
|
}
|
|
|
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
resourceIds: [resource.id],
|
|
});
|
|
|
|
return findCapacityWindows(
|
|
{
|
|
id: resource.id,
|
|
displayName: resource.displayName,
|
|
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
|
|
},
|
|
resourceBookings.map((booking) => ({
|
|
startDate: booking.startDate,
|
|
endDate: booking.endDate,
|
|
hoursPerDay: booking.hoursPerDay,
|
|
status: booking.status,
|
|
})) as Pick<import("@planarchy/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
|
|
input.startDate,
|
|
input.endDate,
|
|
input.minAvailableHoursPerDay,
|
|
);
|
|
}),
|
|
});
|