/** * 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, 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, }; }