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