Files
CapaKraken/packages/staffing/src/skill-matcher.ts
T

179 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Resource, ScoreBreakdown, SkillEntry, StaffingSuggestion } from "@planarchy/shared";
import { SCORE_WEIGHTS } from "@planarchy/shared";
export interface SkillMatchInput {
requiredSkills: string[];
preferredSkills?: string[];
resources: (Pick<Resource, "id" | "displayName" | "eid" | "skills" | "lcrCents" | "chargeabilityTarget"> & {
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<string, SkillEntry>();
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);
}