chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user