refactor(api): extract resource insight procedures
This commit is contained in:
@@ -1,27 +1,9 @@
|
||||
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
listAssignmentBookings,
|
||||
recomputeResourceValueScores,
|
||||
} from "@capakraken/application";
|
||||
import {
|
||||
calculateAllocation,
|
||||
} from "@capakraken/engine";
|
||||
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import {
|
||||
anonymizeResource,
|
||||
anonymizeResources,
|
||||
getAnonymizationDirectory,
|
||||
} from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||
@@ -36,57 +18,14 @@ Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { resolveResourcePermissions } from "../lib/resource-access.js";
|
||||
import { resourceInsightProcedures } from "./resource-insights.js";
|
||||
import { resourceReadProcedures } from "./resource-read.js";
|
||||
|
||||
type BookingForCapacity = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
};
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildDailyBookedHoursMap(
|
||||
bookings: BookingForCapacity[],
|
||||
availability: WeekdayAvailability,
|
||||
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Map<string, number> {
|
||||
const dailyBookedHours = new Map<string, number>();
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const bookedHours = bookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: cursor,
|
||||
periodEnd: cursor,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
dailyBookedHours.set(isoDate, bookedHours);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return dailyBookedHours;
|
||||
}
|
||||
|
||||
export const resourceRouter = createTRPCRouter({
|
||||
...resourceReadProcedures,
|
||||
...resourceInsightProcedures,
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
|
||||
@@ -560,415 +499,6 @@ export const resourceRouter = createTRPCRouter({
|
||||
return { summary };
|
||||
}),
|
||||
|
||||
// ─── Skills Analytics ───────────────────────────────────────────────────────
|
||||
|
||||
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, displayName: true, chapter: true, skills: true },
|
||||
});
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
// Aggregate: { skillName, category, count, totalProficiency, chapters }
|
||||
const skillMap = new Map<
|
||||
string,
|
||||
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
|
||||
>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
const key = s.skill;
|
||||
if (!skillMap.has(key)) {
|
||||
skillMap.set(key, {
|
||||
skill: s.skill,
|
||||
category: s.category ?? "Uncategorized",
|
||||
count: 0,
|
||||
totalProficiency: 0,
|
||||
chapters: new Set(),
|
||||
});
|
||||
}
|
||||
const entry = skillMap.get(key)!;
|
||||
entry.count++;
|
||||
entry.totalProficiency += s.proficiency;
|
||||
if (resource.chapter) entry.chapters.add(resource.chapter);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregated = Array.from(skillMap.values())
|
||||
.map((e) => ({
|
||||
skill: e.skill,
|
||||
category: e.category,
|
||||
count: e.count,
|
||||
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
|
||||
chapters: Array.from(e.chapters),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const categories = [...new Set(aggregated.map((e) => e.category))].sort();
|
||||
const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[];
|
||||
|
||||
return {
|
||||
totalResources: resources.length,
|
||||
totalSkillEntries: aggregated.length,
|
||||
aggregated,
|
||||
categories,
|
||||
allChapters,
|
||||
};
|
||||
}),
|
||||
|
||||
searchBySkills: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
rules: z.array(
|
||||
z.object({
|
||||
skill: z.string().min(1),
|
||||
minProficiency: z.number().int().min(1).max(5).default(1),
|
||||
}),
|
||||
),
|
||||
chapter: z.string().optional(),
|
||||
operator: z.enum(["AND", "OR"]).default("AND"),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { rules, chapter, operator } = input;
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true, ...(chapter ? { chapter } : {}) },
|
||||
select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
|
||||
});
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
const results = resources
|
||||
.map((r) => {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
|
||||
const matchFn = (rule: { skill: string; minProficiency: number }) => {
|
||||
const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase()));
|
||||
return s && s.proficiency >= rule.minProficiency ? s : null;
|
||||
};
|
||||
|
||||
const matched = rules.map(matchFn);
|
||||
const passes =
|
||||
operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
|
||||
|
||||
if (!passes) return null;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
eid: r.eid,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
matchedSkills: rules
|
||||
.map((rule, i) => {
|
||||
const s = matched[i];
|
||||
return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null;
|
||||
})
|
||||
.filter((s): s is { skill: string; proficiency: number; category: string } => s !== null),
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return anonymizeResources(results, directory);
|
||||
}),
|
||||
|
||||
// ─── Self-service ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Get the resource linked to the current user (for self-service pages). */
|
||||
getMyResource: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: ctx.dbUser!.id },
|
||||
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
|
||||
});
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return user?.resource ? anonymizeResource(user.resource, directory) : null;
|
||||
}),
|
||||
|
||||
// ─── Value Score ─────────────────────────────────────────────────────────────
|
||||
|
||||
getValueScores: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
isActive: z.boolean().optional().default(true),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const permissions = resolveResourcePermissions(ctx);
|
||||
requirePermission({ permissions }, PermissionKey.VIEW_SCORES);
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: input.isActive },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
},
|
||||
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
|
||||
take: input.limit,
|
||||
});
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return anonymizeResources(resources, directory);
|
||||
}),
|
||||
|
||||
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
|
||||
return recomputeResourceValueScores(ctx.db);
|
||||
}),
|
||||
|
||||
listWithUtilization: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
chapter: z.string().optional(),
|
||||
includeProposed: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
take: input.limit,
|
||||
orderBy: { displayName: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
skills: true,
|
||||
dynamicFields: true,
|
||||
blueprintId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
roleId: true,
|
||||
portfolioUrl: true,
|
||||
postalCode: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
userId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
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 contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.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,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
|
||||
(booking) =>
|
||||
booking.resourceId === r.id &&
|
||||
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||
);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
|
||||
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const dayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date,
|
||||
context,
|
||||
});
|
||||
return dayCapacity > 0 && hours > dayCapacity;
|
||||
});
|
||||
|
||||
const utilizationPercent =
|
||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||
|
||||
return anonymizeResource({
|
||||
...r,
|
||||
bookingCount: resourceBookings.length,
|
||||
bookedHours: Math.round(bookedHours),
|
||||
availableHours: Math.round(availableHours),
|
||||
utilizationPercent,
|
||||
isOverbooked,
|
||||
}, directory);
|
||||
});
|
||||
}),
|
||||
|
||||
getChargeabilityStats: controllerProcedure
|
||||
.input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.resourceId ? { id: input.resourceId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.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,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
|
||||
const actualAllocs = resourceBookings.filter((booking) =>
|
||||
isChargeabilityActualBooking(booking, input.includeProposed),
|
||||
);
|
||||
|
||||
const expectedAllocs = resourceBookings.filter((booking) =>
|
||||
isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability: avail,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expectedBookedHours = expectedAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.round((actualBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.round((expectedBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
|
||||
return anonymizeResource({
|
||||
id: r.id,
|
||||
eid: r.eid,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
chargeabilityTarget: r.chargeabilityTarget,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
availableHours: Math.round(availableHours),
|
||||
}, directory);
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
|
||||
*/
|
||||
@@ -1002,272 +532,4 @@ export const resourceRouter = createTRPCRouter({
|
||||
return { updated: input.ids.length };
|
||||
}),
|
||||
|
||||
// ─── Skill Marketplace ────────────────────────────────────────────────────
|
||||
|
||||
getSkillMarketplace: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
// Section 1: Skill search
|
||||
searchSkill: z.string().optional(),
|
||||
minProficiency: z.number().int().min(1).max(5).optional().default(1),
|
||||
availableOnly: z.boolean().optional().default(false),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
// ── Fetch all active resources with skills ──
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
chapter: true,
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Fetch current assignments for utilization calc ──
|
||||
const allResourceIds = resources.map((r) => r.id);
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: today },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.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,
|
||||
})),
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
const assignmentsByResourceId = new Map<string, typeof assignments>();
|
||||
for (const assignment of assignments) {
|
||||
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
||||
items.push(assignment);
|
||||
assignmentsByResourceId.set(assignment.resourceId, items);
|
||||
}
|
||||
|
||||
// Build utilization map with holiday-aware daily capacity over the next 30 days.
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
|
||||
const todayAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
});
|
||||
const todayBookedHours = resourceAssignments.reduce(
|
||||
(sum, assignment) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const utilizationPercent = todayAvailableHours > 0
|
||||
? Math.round((todayBookedHours / todayAvailableHours) * 100)
|
||||
: 0;
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(
|
||||
resourceAssignments,
|
||||
availability,
|
||||
context,
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(today);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const dayAvailableHours = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: checkDate,
|
||||
context,
|
||||
});
|
||||
if (dayAvailableHours > 0) {
|
||||
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
|
||||
if (dayBookedHours < dayAvailableHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
}
|
||||
|
||||
// ── Section 1: Skill Search ──
|
||||
let searchResults: Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
skillProficiency: number;
|
||||
skillName: string;
|
||||
utilizationPercent: number;
|
||||
availableFrom: string | null;
|
||||
}> = [];
|
||||
|
||||
if (input.searchSkill && input.searchSkill.trim().length > 0) {
|
||||
const needle = input.searchSkill.toLowerCase();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
const match = skills.find(
|
||||
(s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency,
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const util = utilizationMap.get(r.id);
|
||||
if (input.availableOnly && !util?.earliestAvailableDate) continue;
|
||||
|
||||
searchResults.push({
|
||||
id: r.id,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
skillProficiency: match.proficiency,
|
||||
skillName: match.skill,
|
||||
utilizationPercent: util?.utilizationPercent ?? 0,
|
||||
availableFrom: util?.earliestAvailableDate?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent);
|
||||
}
|
||||
|
||||
// ── Section 2: Skill Gap Heat Map ──
|
||||
// Demand: from unfilled DemandRequirements + project staffingReqs skills
|
||||
const unfilled = await ctx.db.demandRequirement.findMany({
|
||||
where: {
|
||||
endDate: { gte: now },
|
||||
assignments: { none: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
project: {
|
||||
select: { staffingReqs: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Collect demanded skills from project staffingReqs
|
||||
const demandSkillCounts = new Map<string, number>();
|
||||
for (const demand of unfilled) {
|
||||
const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
|
||||
role?: string;
|
||||
roleId?: string;
|
||||
requiredSkills?: string[];
|
||||
}>) ?? [];
|
||||
|
||||
// Match demand to staffing req by role or roleId
|
||||
const matchedReq = staffingReqs.find(
|
||||
(sr) =>
|
||||
(demand.roleId && sr.roleId === demand.roleId) ||
|
||||
(demand.role && sr.role === demand.role),
|
||||
);
|
||||
|
||||
if (matchedReq?.requiredSkills) {
|
||||
for (const skill of matchedReq.requiredSkills) {
|
||||
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supply: count resources with skill at proficiency >= 3
|
||||
const supplySkillCounts = new Map<string, number>();
|
||||
const allSkillCounts = new Map<string, number>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1);
|
||||
if (s.proficiency >= 3) {
|
||||
supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all skill names from both demand and supply
|
||||
const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]);
|
||||
const gapData = Array.from(allGapSkills)
|
||||
.map((skill) => {
|
||||
const supply = supplySkillCounts.get(skill) ?? 0;
|
||||
const demand = demandSkillCounts.get(skill) ?? 0;
|
||||
return { skill, supply, demand, gap: demand - supply };
|
||||
})
|
||||
.sort((a, b) => b.gap - a.gap);
|
||||
|
||||
// ── Section 3: Distribution (top 20 by resource count) ──
|
||||
const aggregated = Array.from(
|
||||
(() => {
|
||||
const map = new Map<string, { skill: string; count: number; totalProficiency: number }>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
const entry = map.get(s.skill);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
entry.totalProficiency += s.proficiency;
|
||||
} else {
|
||||
map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})().values(),
|
||||
)
|
||||
.map((e) => ({
|
||||
skill: e.skill,
|
||||
count: e.count,
|
||||
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20);
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return {
|
||||
searchResults: anonymizeResources(searchResults, directory),
|
||||
gapData,
|
||||
distribution: aggregated,
|
||||
totalResources: resources.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user