277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
};
|