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