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(); for (const assignment of assignments) { const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; items.push(assignment); assignmentsByResourceId.set(assignment.resourceId, items); } const utilizationMap = new Map(); 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(); 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(); 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(); 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, }; }), };