216 lines
6.7 KiB
TypeScript
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,
|
|
};
|
|
}
|