refactor(api): extract resource insight procedures
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
import { recomputeResourceValueScores } from "@capakraken/application";
|
||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
anonymizeResource,
|
||||||
|
anonymizeResources,
|
||||||
|
getAnonymizationDirectory,
|
||||||
|
} from "../lib/anonymization.js";
|
||||||
|
import { resolveResourcePermissions } from "../lib/resource-access.js";
|
||||||
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
controllerProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
requirePermission,
|
||||||
|
} from "../trpc.js";
|
||||||
|
|
||||||
|
type SkillRow = {
|
||||||
|
skill: string;
|
||||||
|
category?: string;
|
||||||
|
proficiency: number;
|
||||||
|
isMainSkill?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceAnalyticsProcedures = {
|
||||||
|
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const resources = await ctx.db.resource.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, displayName: true, chapter: true, skills: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 skill of skills) {
|
||||||
|
const key = skill.skill;
|
||||||
|
if (!skillMap.has(key)) {
|
||||||
|
skillMap.set(key, {
|
||||||
|
skill: skill.skill,
|
||||||
|
category: skill.category ?? "Uncategorized",
|
||||||
|
count: 0,
|
||||||
|
totalProficiency: 0,
|
||||||
|
chapters: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entry = skillMap.get(key)!;
|
||||||
|
entry.count++;
|
||||||
|
entry.totalProficiency += skill.proficiency;
|
||||||
|
if (resource.chapter) entry.chapters.add(resource.chapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated = Array.from(skillMap.values())
|
||||||
|
.map((entry) => ({
|
||||||
|
skill: entry.skill,
|
||||||
|
category: entry.category,
|
||||||
|
count: entry.count,
|
||||||
|
avgProficiency: Math.round((entry.totalProficiency / entry.count) * 10) / 10,
|
||||||
|
chapters: Array.from(entry.chapters),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.count - left.count);
|
||||||
|
|
||||||
|
const categories = [...new Set(aggregated.map((entry) => entry.category))].sort();
|
||||||
|
const allChapters = [...new Set(resources.map((resource) => resource.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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = resources
|
||||||
|
.map((resource) => {
|
||||||
|
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||||
|
|
||||||
|
const matchFn = (rule: { skill: string; minProficiency: number }) => {
|
||||||
|
const matchedSkill = skills.find((skill) => skill.skill.toLowerCase().includes(rule.skill.toLowerCase()));
|
||||||
|
return matchedSkill && matchedSkill.proficiency >= rule.minProficiency ? matchedSkill : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matched = rules.map(matchFn);
|
||||||
|
const passes = operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
|
||||||
|
|
||||||
|
if (!passes) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
chapter: resource.chapter,
|
||||||
|
matchedSkills: rules
|
||||||
|
.map((rule, index) => {
|
||||||
|
const matchedSkill = matched[index];
|
||||||
|
return matchedSkill
|
||||||
|
? {
|
||||||
|
skill: matchedSkill.skill,
|
||||||
|
proficiency: matchedSkill.proficiency,
|
||||||
|
category: matchedSkill.category ?? "",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((skill): skill is { skill: string; proficiency: number; category: string } => skill !== null),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((resource): resource is NonNullable<typeof resource> => resource !== null)
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
||||||
|
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return anonymizeResources(results, directory);
|
||||||
|
}),
|
||||||
|
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
isChargeabilityActualBooking,
|
||||||
|
isChargeabilityRelevantProject,
|
||||||
|
listAssignmentBookings,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
anonymizeResource,
|
||||||
|
getAnonymizationDirectory,
|
||||||
|
} from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
calculateEffectiveDayAvailability,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
import { controllerProcedure } from "../trpc.js";
|
||||||
|
import { buildDailyBookedHoursMap } from "./resource-capacity-shared.js";
|
||||||
|
|
||||||
|
export const resourceCapacityReadProcedures = {
|
||||||
|
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((resource) => {
|
||||||
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = contexts.get(resource.id);
|
||||||
|
const resourceBookings = (bookingsByResourceId.get(resource.id) ?? []).filter(
|
||||||
|
(booking) =>
|
||||||
|
booking.resourceId === resource.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({
|
||||||
|
...resource,
|
||||||
|
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((resource) => {
|
||||||
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = contexts.get(resource.id);
|
||||||
|
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||||
|
|
||||||
|
const actualAllocs = resourceBookings.filter((booking) =>
|
||||||
|
isChargeabilityActualBooking(booking, input.includeProposed),
|
||||||
|
);
|
||||||
|
const expectedAllocs = resourceBookings.filter((booking) =>
|
||||||
|
isChargeabilityRelevantProject(booking.project, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const actualBookedHours = actualAllocs.reduce(
|
||||||
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const expectedBookedHours = expectedAllocs.reduce(
|
||||||
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
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: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
chapter: resource.chapter,
|
||||||
|
chargeabilityTarget: resource.chargeabilityTarget,
|
||||||
|
actualChargeability,
|
||||||
|
expectedChargeability,
|
||||||
|
availableHours: Math.round(availableHours),
|
||||||
|
}, directory);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import { calculateEffectiveBookedHours } from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
|
type BookingForCapacity = {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toIsoDate(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { resourceAnalyticsProcedures } from "./resource-analytics.js";
|
||||||
|
import { resourceCapacityReadProcedures } from "./resource-capacity-read.js";
|
||||||
|
import { resourceMarketplaceReadProcedures } from "./resource-marketplace-read.js";
|
||||||
|
|
||||||
|
export const resourceInsightProcedures = {
|
||||||
|
...resourceAnalyticsProcedures,
|
||||||
|
...resourceCapacityReadProcedures,
|
||||||
|
...resourceMarketplaceReadProcedures,
|
||||||
|
};
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
anonymizeResources,
|
||||||
|
getAnonymizationDirectory,
|
||||||
|
} from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
calculateEffectiveDayAvailability,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
import { controllerProcedure } from "../trpc.js";
|
||||||
|
import { buildDailyBookedHoursMap, toIsoDate } from "./resource-capacity-shared.js";
|
||||||
|
|
||||||
|
type SkillRow = {
|
||||||
|
skill: string;
|
||||||
|
category?: string;
|
||||||
|
proficiency: number;
|
||||||
|
isMainSkill?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceMarketplaceReadProcedures = {
|
||||||
|
getSkillMarketplace: controllerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
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);
|
||||||
|
|
||||||
|
const resources = await ctx.db.resource.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
eid: true,
|
||||||
|
chapter: true,
|
||||||
|
skills: true,
|
||||||
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignments = await ctx.db.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
resourceId: { in: resources.map((resource) => resource.id) },
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||||
|
for (const resource of resources) {
|
||||||
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = contexts.get(resource.id);
|
||||||
|
const resourceAssignments = assignmentsByResourceId.get(resource.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 index = 0; index < 30; index++) {
|
||||||
|
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(resource.id, { utilizationPercent, earliestAvailableDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resource of resources) {
|
||||||
|
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||||
|
const match = skills.find(
|
||||||
|
(skill) => skill.skill.toLowerCase().includes(needle) && skill.proficiency >= input.minProficiency,
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const utilization = utilizationMap.get(resource.id);
|
||||||
|
if (input.availableOnly && !utilization?.earliestAvailableDate) continue;
|
||||||
|
|
||||||
|
searchResults.push({
|
||||||
|
id: resource.id,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
chapter: resource.chapter,
|
||||||
|
skillProficiency: match.proficiency,
|
||||||
|
skillName: match.skill,
|
||||||
|
utilizationPercent: utilization?.utilizationPercent ?? 0,
|
||||||
|
availableFrom: utilization?.earliestAvailableDate?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
searchResults.sort((left, right) => (
|
||||||
|
right.skillProficiency - left.skillProficiency || left.utilizationPercent - right.utilizationPercent
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}>) ?? [];
|
||||||
|
const matchedReq = staffingReqs.find(
|
||||||
|
(staffingReq) =>
|
||||||
|
(demand.roleId && staffingReq.roleId === demand.roleId) ||
|
||||||
|
(demand.role && staffingReq.role === demand.role),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedReq?.requiredSkills) {
|
||||||
|
for (const skill of matchedReq.requiredSkills) {
|
||||||
|
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplySkillCounts = new Map<string, number>();
|
||||||
|
for (const resource of resources) {
|
||||||
|
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (skill.proficiency >= 3) {
|
||||||
|
supplySkillCounts.set(skill.skill, (supplySkillCounts.get(skill.skill) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gapData = Array.from(new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]))
|
||||||
|
.map((skill) => {
|
||||||
|
const supply = supplySkillCounts.get(skill) ?? 0;
|
||||||
|
const demand = demandSkillCounts.get(skill) ?? 0;
|
||||||
|
return { skill, supply, demand, gap: demand - supply };
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.gap - left.gap);
|
||||||
|
|
||||||
|
const distribution = Array.from(
|
||||||
|
(() => {
|
||||||
|
const skillMap = new Map<string, { skill: string; count: number; totalProficiency: number }>();
|
||||||
|
for (const resource of resources) {
|
||||||
|
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||||
|
for (const skill of skills) {
|
||||||
|
const entry = skillMap.get(skill.skill);
|
||||||
|
if (entry) {
|
||||||
|
entry.count++;
|
||||||
|
entry.totalProficiency += skill.proficiency;
|
||||||
|
} else {
|
||||||
|
skillMap.set(skill.skill, {
|
||||||
|
skill: skill.skill,
|
||||||
|
count: 1,
|
||||||
|
totalProficiency: skill.proficiency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skillMap.values();
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
.map((entry) => ({
|
||||||
|
skill: entry.skill,
|
||||||
|
count: entry.count,
|
||||||
|
avgProficiency: Math.round((entry.totalProficiency / entry.count) * 10) / 10,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchResults: anonymizeResources(searchResults, directory),
|
||||||
|
gapData,
|
||||||
|
distribution,
|
||||||
|
totalResources: resources.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,27 +1,9 @@
|
|||||||
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
||||||
import {
|
|
||||||
isChargeabilityActualBooking,
|
|
||||||
isChargeabilityRelevantProject,
|
|
||||||
listAssignmentBookings,
|
|
||||||
recomputeResourceValueScores,
|
|
||||||
} from "@capakraken/application";
|
|
||||||
import {
|
import {
|
||||||
calculateAllocation,
|
calculateAllocation,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
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";
|
import { logger } from "../lib/logger.js";
|
||||||
|
|
||||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
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 { 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";
|
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({
|
export const resourceRouter = createTRPCRouter({
|
||||||
...resourceReadProcedures,
|
...resourceReadProcedures,
|
||||||
|
...resourceInsightProcedures,
|
||||||
|
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
|
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
|
||||||
@@ -560,415 +499,6 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
return { summary };
|
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).
|
* 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 };
|
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