import type { Resource, ScoreBreakdown, SkillEntry, StaffingSuggestion } from "@capakraken/shared"; import { SCORE_WEIGHTS } from "@capakraken/shared"; export interface SkillMatchInput { requiredSkills: string[]; preferredSkills?: string[]; resources: (Pick & { currentUtilizationPercent: number; hasAvailabilityConflicts: boolean; conflictDays?: string[]; })[]; budgetLcrCentsPerHour?: number; } /** * Computes a skill match score (0-100) for a resource against required skills. * Factors in proficiency levels and coverage. */ export function computeSkillScore( resourceSkills: SkillEntry[], requiredSkills: string[], preferredSkills: string[] = [], ): { score: number; matchedRequired: string[]; matchedPreferred: string[]; missing: string[] } { if (requiredSkills.length === 0) { return { score: 100, matchedRequired: [], matchedPreferred: [], missing: [] }; } const resourceSkillMap = new Map(); for (const skill of resourceSkills) { resourceSkillMap.set(skill.skill.toLowerCase(), skill); } const matchedRequired: string[] = []; const missing: string[] = []; // Required skills contribute 70% of score let requiredScore = 0; for (const req of requiredSkills) { const match = resourceSkillMap.get(req.toLowerCase()); if (match) { matchedRequired.push(req); // Proficiency 1-5, normalized to 0-1, weighted requiredScore += (match.proficiency / 5) * (70 / requiredSkills.length); } else { missing.push(req); } } // Preferred skills contribute 30% as a bonus on top of required (70%). // If no preferred skills defined, required score is normalized to 100%. const matchedPreferred: string[] = []; let preferredScore = 0; if (preferredSkills.length > 0) { for (const pref of preferredSkills) { const match = resourceSkillMap.get(pref.toLowerCase()); if (match) { matchedPreferred.push(pref); preferredScore += (match.proficiency / 5) * (30 / preferredSkills.length); } } } else { // No preferred skills: normalize required score to 100-point scale return { score: Math.min(100, Math.round((requiredScore / 70) * 100)), matchedRequired, matchedPreferred: [], missing, }; } return { score: Math.min(100, Math.round(requiredScore + preferredScore)), matchedRequired, matchedPreferred, missing, }; } /** * Computes availability score (0-100) based on whether conflicts exist. */ export function computeAvailabilityScore( hasConflicts: boolean, conflictDayCount = 0, ): number { if (!hasConflicts) return 100; // Reduce score by 10 per conflict day, minimum 0 return Math.max(0, 100 - conflictDayCount * 10); } /** * Computes cost score (0-100). Lower LCR = higher score. * Normalized against a budget target. */ export function computeCostScore( resourceLcrCents: number, budgetLcrCentsPerHour?: number, ): number { if (!budgetLcrCentsPerHour || budgetLcrCentsPerHour <= 0) return 50; // Neutral if (resourceLcrCents <= budgetLcrCentsPerHour) return 100; // Above budget: score decreases linearly, 0 at 2× budget const ratio = resourceLcrCents / budgetLcrCentsPerHour; return Math.max(0, Math.round(100 - (ratio - 1) * 100)); } /** * Computes utilization score (0-100). * Prefers resources with utilization below their chargeability target * (i.e., capacity to absorb new work). */ export function computeUtilizationScore( currentUtilizationPercent: number, chargeabilityTarget: number, ): number { const gap = chargeabilityTarget - currentUtilizationPercent; if (gap >= 20) return 100; // Well under target — great candidate if (gap >= 0) return 60 + gap * 2; // Slightly under target if (gap >= -20) return Math.max(0, 60 + gap * 2); // Slightly over return 0; // Severely overallocated } /** * Multi-factor staffing scorer. * Returns ranked suggestions with score breakdowns. * * Score weights: * Skill 40% * Availability 30% * Cost 20% * Utilization 10% */ export function rankResources(input: SkillMatchInput): StaffingSuggestion[] { const { requiredSkills, preferredSkills, resources, budgetLcrCentsPerHour } = input; const suggestions: StaffingSuggestion[] = resources.map((resource) => { const skillResult = computeSkillScore(resource.skills, requiredSkills, preferredSkills); const availScore = computeAvailabilityScore( resource.hasAvailabilityConflicts, resource.conflictDays?.length, ); const costScore = computeCostScore(resource.lcrCents, budgetLcrCentsPerHour); const utilScore = computeUtilizationScore( resource.currentUtilizationPercent, resource.chargeabilityTarget, ); const total = Math.round( skillResult.score * SCORE_WEIGHTS.SKILL + availScore * SCORE_WEIGHTS.AVAILABILITY + costScore * SCORE_WEIGHTS.COST + utilScore * SCORE_WEIGHTS.UTILIZATION, ); const scoreBreakdown: ScoreBreakdown = { skillScore: Math.round(skillResult.score), availabilityScore: Math.round(availScore), costScore: Math.round(costScore), utilizationScore: Math.round(utilScore), total, }; return { resourceId: resource.id, resourceName: resource.displayName, eid: resource.eid, score: total, scoreBreakdown, matchedSkills: [...skillResult.matchedRequired, ...skillResult.matchedPreferred], missingSkills: skillResult.missing, availabilityConflicts: resource.conflictDays ?? [], estimatedDailyCostCents: resource.lcrCents * 8, currentUtilization: resource.currentUtilizationPercent, }; }); // Sort by score descending return suggestions.sort((a, b) => b.score - a.score); }