Files
CapaKraken/packages/engine/src/estimate/experience-multiplier.ts
T

216 lines
6.7 KiB
TypeScript

/**
* Pure experience-multiplier engine.
* Applies parametric rate adjustments (cost/bill multipliers) and
* shoring ratios (offshore/nearshore effort factors) to demand lines.
* No DB or IO dependencies.
*/
export interface ExperienceMultiplierRule {
chapter?: string | null;
location?: string | null;
level?: string | null;
costMultiplier: number;
billMultiplier: number;
shoringRatio?: number | null;
additionalEffortRatio?: number | null;
description?: string | null;
}
export interface RateAdjustmentInput {
costRateCents: number;
billRateCents: number;
hours: number;
chapter?: string | null;
location?: string | null;
level?: string | null;
}
export interface RateAdjustmentResult {
adjustedCostRateCents: number;
adjustedBillRateCents: number;
adjustedHours: number;
appliedRules: string[];
}
/**
* Compute a specificity score for a rule given matching dimensions.
* chapter=4, location=2, level=1 — higher means more specific.
*/
function ruleSpecificity(rule: ExperienceMultiplierRule): number {
let score = 0;
if (rule.chapter != null && rule.chapter !== "") score += 4;
if (rule.location != null && rule.location !== "") score += 2;
if (rule.level != null && rule.level !== "") score += 1;
return score;
}
function normalise(value: string | null | undefined): string {
return (value ?? "").toLowerCase().trim();
}
function fieldMatches(
ruleValue: string | null | undefined,
inputValue: string | null | undefined,
): boolean {
const rv = normalise(ruleValue);
if (rv === "") return true; // wildcard — matches everything
return rv === normalise(inputValue);
}
/**
* Find the best matching rule using hierarchical specificity.
*
* Matching priority (most specific first):
* 1. chapter + location + level
* 2. chapter + location
* 3. chapter + level
* 4. chapter only
* 5. location + level
* 6. location only
* 7. level only
* 8. Global fallback (no filters)
*
* When multiple rules share the same specificity, the first one wins.
*/
export function findBestMatchingRule(
input: Pick<RateAdjustmentInput, "chapter" | "location" | "level">,
rules: ExperienceMultiplierRule[],
): ExperienceMultiplierRule | null {
let bestRule: ExperienceMultiplierRule | null = null;
let bestScore = -1;
for (const rule of rules) {
const chapterMatch = fieldMatches(rule.chapter, input.chapter);
const locationMatch = fieldMatches(rule.location, input.location);
const levelMatch = fieldMatches(rule.level, input.level);
if (!chapterMatch || !locationMatch || !levelMatch) continue;
const score = ruleSpecificity(rule);
if (score > bestScore) {
bestScore = score;
bestRule = rule;
}
}
return bestRule;
}
/**
* Apply experience multipliers and shoring ratios to a single rate input.
*
* - Matches the most specific rule by chapter/location/level.
* - Multiplies cost and bill rates (rounds to integer cents).
* - Adjusts hours via shoring ratio and additional effort ratio:
* `adjustedHours = onsiteHours + offshoreHours * (1 + additionalEffortRatio)`
* where `onsiteHours = hours * (1 - shoringRatio)` and `offshoreHours = hours * shoringRatio`.
*/
export function applyExperienceMultipliers(
input: RateAdjustmentInput,
rules: ExperienceMultiplierRule[],
): RateAdjustmentResult {
const result: RateAdjustmentResult = {
adjustedCostRateCents: input.costRateCents,
adjustedBillRateCents: input.billRateCents,
adjustedHours: input.hours,
appliedRules: [],
};
if (rules.length === 0) {
result.appliedRules.push("No rules provided — values unchanged.");
return result;
}
const matched = findBestMatchingRule(input, rules);
if (!matched) {
result.appliedRules.push("No matching rule found — values unchanged.");
return result;
}
// Describe the match for the audit trail
const matchParts: string[] = [];
if (matched.chapter) matchParts.push(`chapter=${matched.chapter}`);
if (matched.location) matchParts.push(`location=${matched.location}`);
if (matched.level) matchParts.push(`level=${matched.level}`);
const matchLabel = matchParts.length > 0 ? matchParts.join(", ") : "global fallback";
// Apply rate multipliers
result.adjustedCostRateCents = Math.round(input.costRateCents * matched.costMultiplier);
result.adjustedBillRateCents = Math.round(input.billRateCents * matched.billMultiplier);
if (matched.costMultiplier !== 1.0 || matched.billMultiplier !== 1.0) {
result.appliedRules.push(
`Rate multipliers applied (${matchLabel}): cost x${matched.costMultiplier}, bill x${matched.billMultiplier}.`,
);
}
// Apply shoring ratio
const shoringRatio = matched.shoringRatio ?? 0;
const additionalEffort = matched.additionalEffortRatio ?? 0;
if (shoringRatio > 0) {
const onsiteHours = input.hours * (1 - shoringRatio);
const offshoreHours = input.hours * shoringRatio * (1 + additionalEffort);
result.adjustedHours = Math.round((onsiteHours + offshoreHours) * 100) / 100;
result.appliedRules.push(
`Shoring applied (${matchLabel}): ${(shoringRatio * 100).toFixed(0)}% shored` +
(additionalEffort > 0
? `, +${(additionalEffort * 100).toFixed(0)}% additional effort on shored portion`
: "") +
` => ${result.adjustedHours}h (from ${input.hours}h).`,
);
}
if (result.appliedRules.length === 0) {
result.appliedRules.push(`Matched rule (${matchLabel}) — multipliers are 1.0, no shoring; values unchanged.`);
}
if (matched.description) {
result.appliedRules.push(`Rule note: ${matched.description}`);
}
return result;
}
/**
* Batch-apply experience multipliers to multiple demand line inputs.
* Returns per-line results and an overall summary.
*/
export function applyExperienceMultipliersBatch(
inputs: RateAdjustmentInput[],
rules: ExperienceMultiplierRule[],
): {
results: RateAdjustmentResult[];
totalOriginalHours: number;
totalAdjustedHours: number;
linesAdjusted: number;
} {
const results = inputs.map((input) => applyExperienceMultipliers(input, rules));
let totalOriginalHours = 0;
let totalAdjustedHours = 0;
let linesAdjusted = 0;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]!;
const result = results[i]!;
totalOriginalHours += input.hours;
totalAdjustedHours += result.adjustedHours;
if (
result.adjustedCostRateCents !== input.costRateCents ||
result.adjustedBillRateCents !== input.billRateCents ||
result.adjustedHours !== input.hours
) {
linesAdjusted++;
}
}
return {
results,
totalOriginalHours: Math.round(totalOriginalHours * 100) / 100,
totalAdjustedHours: Math.round(totalAdjustedHours * 100) / 100,
linesAdjusted,
};
}