refactor(api): extract resource insight procedures
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user