feat: calculation rules engine for decoupled cost attribution and chargeability
Introduces an admin-configurable rules engine that determines per-day cost attribution (CHARGE/ZERO/REDUCE) and chargeability reporting (COUNT/SKIP) for absence types (sick, vacation, public holiday). Includes shared types, Zod schemas, Prisma model, rule matching with specificity scoring, default rules, calculator integration, CRUD API router, seed data, chargeability report integration, and admin UI. 283/283 engine tests, 209/209 API tests, 0 TS errors. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Calculation Rules Engine — matches absence days against rules
|
||||
* to determine cost and chargeability effects.
|
||||
*
|
||||
* Pure function — no DB imports.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AbsenceTrigger,
|
||||
CalculationRule,
|
||||
CostEffect,
|
||||
ChargeabilityEffect,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export interface RuleMatch {
|
||||
rule: CalculationRule;
|
||||
costEffect: CostEffect;
|
||||
chargeabilityEffect: ChargeabilityEffect;
|
||||
costReductionPercent: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specificity score for a rule — more specific filters = higher score.
|
||||
*/
|
||||
function specificityScore(rule: CalculationRule): number {
|
||||
let score = 0;
|
||||
if (rule.projectId) score += 2;
|
||||
if (rule.orderType) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching rule for a given absence day.
|
||||
*
|
||||
* Matching:
|
||||
* 1. triggerType must match
|
||||
* 2. isActive must be true
|
||||
* 3. projectId must match (null = all projects)
|
||||
* 4. orderType must match (null = all order types)
|
||||
*
|
||||
* Ranking: highest specificity wins, then highest priority.
|
||||
*/
|
||||
export function findMatchingRule(
|
||||
rules: CalculationRule[],
|
||||
triggerType: AbsenceTrigger,
|
||||
projectId?: string | null,
|
||||
orderType?: string | null,
|
||||
): RuleMatch | null {
|
||||
const candidates = rules.filter((r) => {
|
||||
if (!r.isActive) return false;
|
||||
if (r.triggerType !== triggerType) return false;
|
||||
if (r.projectId && r.projectId !== projectId) return false;
|
||||
if (r.orderType && r.orderType !== orderType) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Sort by specificity (desc), then priority (desc)
|
||||
candidates.sort((a, b) => {
|
||||
const specDiff = specificityScore(b) - specificityScore(a);
|
||||
if (specDiff !== 0) return specDiff;
|
||||
return b.priority - a.priority;
|
||||
});
|
||||
|
||||
const best = candidates[0]!;
|
||||
return {
|
||||
rule: best,
|
||||
costEffect: best.costEffect,
|
||||
chargeabilityEffect: best.chargeabilityEffect,
|
||||
costReductionPercent: best.costReductionPercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cost effect to a cost value.
|
||||
*/
|
||||
export function applyCostEffect(
|
||||
normalCostCents: number,
|
||||
costEffect: CostEffect,
|
||||
reductionPercent: number | null,
|
||||
): number {
|
||||
switch (costEffect) {
|
||||
case "CHARGE":
|
||||
return normalCostCents;
|
||||
case "ZERO":
|
||||
return 0;
|
||||
case "REDUCE":
|
||||
return Math.round(normalCostCents * (100 - (reductionPercent ?? 0)) / 100);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user