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:
2026-03-15 09:29:12 +01:00
parent a83edb2f9d
commit 368fd6d7ad
23 changed files with 1753 additions and 53 deletions
@@ -0,0 +1,243 @@
import { describe, expect, it } from "vitest";
import { calculateAllocation } from "../allocation/calculator.js";
import { DEFAULT_CALCULATION_RULES } from "../rules/default-rules.js";
import type { WeekdayAvailability, AllocationCalculationInput } from "@planarchy/shared";
import type { CalculationRule } from "@planarchy/shared";
const STD_AVAILABILITY: WeekdayAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
};
const now = new Date();
function makeRule(overrides: Partial<CalculationRule>): CalculationRule {
return {
id: "rule_1",
name: "Test Rule",
description: null,
triggerType: "SICK",
projectId: null,
orderType: null,
costEffect: "ZERO",
costReductionPercent: null,
chargeabilityEffect: "COUNT",
priority: 0,
isActive: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("calculateAllocation with rules", () => {
it("backward compatible: no rules = legacy behavior", () => {
// Monday 2026-03-02 to Friday 2026-03-06 (5 working days)
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
});
expect(result.workingDays).toBe(5);
expect(result.totalHours).toBe(40);
expect(result.totalCostCents).toBe(4000);
expect(result.totalChargeableHours).toBeUndefined();
expect(result.totalProjectCostCents).toBeUndefined();
});
it("legacy vacation blocks the day without rules", () => {
// Monday 2026-03-02 to Friday 2026-03-06, Tuesday is vacation
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
vacationDates: [new Date("2026-03-03")], // Tuesday
});
expect(result.workingDays).toBe(4);
expect(result.totalHours).toBe(32);
expect(result.totalCostCents).toBe(3200);
});
it("sick day with rules: person chargeable, project not charged", () => {
const sickRule = makeRule({
triggerType: "SICK",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
});
// Monday 2026-03-02 to Friday 2026-03-06, Wednesday is sick
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
absenceDays: [{ date: new Date("2026-03-04"), type: "SICK" }],
calculationRules: [sickRule],
});
// Person worked 4 days (sick day hours = 0 effective)
expect(result.workingDays).toBe(5); // still counted as working day
expect(result.totalHours).toBe(32); // 4 days x 8h (sick day effective = 0)
// Project cost: 4 days x 800 = 3200 (sick day = ZERO cost to project)
expect(result.totalProjectCostCents).toBe(3200);
// Chargeability: all 5 days count (8h x 5 = 40h)
expect(result.totalChargeableHours).toBe(40);
// Check the sick day breakdown entry (index 2 = Wednesday = 3rd day)
const sickDay = result.dailyBreakdown[2]!;
expect(sickDay.absenceType).toBe("SICK");
expect(sickDay.hours).toBe(0); // not worked
expect(sickDay.costCents).toBe(0); // not charged to project
expect(sickDay.chargeableHours).toBe(8); // counts toward chargeability
});
it("vacation with rules: person chargeable, project not charged", () => {
const vacationRule = makeRule({
triggerType: "VACATION",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
});
// Mon-Fri, Tuesday is vacation via absenceDays
const result = calculateAllocation({
lcrCents: 150,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
absenceDays: [{ date: new Date("2026-03-03"), type: "VACATION" }],
calculationRules: [vacationRule],
});
expect(result.totalHours).toBe(32);
expect(result.totalProjectCostCents).toBe(4800); // 4 x 8 x 150
expect(result.totalChargeableHours).toBe(40); // all 5 days
});
it("vacation via legacy vacationDates + rules", () => {
const vacationRule = makeRule({
triggerType: "VACATION",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
});
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
vacationDates: [new Date("2026-03-03")],
calculationRules: [vacationRule],
});
expect(result.totalHours).toBe(32);
expect(result.totalProjectCostCents).toBe(3200);
expect(result.totalChargeableHours).toBe(40);
});
it("public holiday with rules: not chargeable, not charged", () => {
const holidayRule = makeRule({
triggerType: "PUBLIC_HOLIDAY",
costEffect: "ZERO",
chargeabilityEffect: "SKIP",
});
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
absenceDays: [{ date: new Date("2026-03-04"), type: "PUBLIC_HOLIDAY" }],
calculationRules: [holidayRule],
});
expect(result.totalHours).toBe(32);
expect(result.totalProjectCostCents).toBe(3200);
// Public holiday SKIP → chargeableHours = 0 for that day
expect(result.totalChargeableHours).toBe(32);
});
it("REDUCE cost effect applies percentage", () => {
const rule = makeRule({
triggerType: "SICK",
costEffect: "REDUCE",
costReductionPercent: 50,
chargeabilityEffect: "COUNT",
});
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
absenceDays: [{ date: new Date("2026-03-04"), type: "SICK" }],
calculationRules: [rule],
});
// Sick day: 8h x 100 cents = 800 reduced by 50% = 400
// Other 4 days: 4 x 800 = 3200
expect(result.totalProjectCostCents).toBe(3600);
expect(result.totalChargeableHours).toBe(40);
});
it("half-day sick: partial effect", () => {
const sickRule = makeRule({
triggerType: "SICK",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
});
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-04"), // single Wednesday
endDate: new Date("2026-03-04"),
availability: STD_AVAILABILITY,
absenceDays: [{ date: new Date("2026-03-04"), type: "SICK", isHalfDay: true }],
calculationRules: [sickRule],
});
// Half day sick: 4h worked, 4h absent
expect(result.totalHours).toBe(4); // worked portion
expect(result.totalProjectCostCents).toBe(400); // only worked portion charged
expect(result.totalChargeableHours).toBe(8); // full day counts (COUNT rule)
});
it("uses default rules when provided", () => {
const result = calculateAllocation({
lcrCents: 100,
hoursPerDay: 8,
startDate: new Date("2026-03-02"),
endDate: new Date("2026-03-06"),
availability: STD_AVAILABILITY,
absenceDays: [
{ date: new Date("2026-03-03"), type: "SICK" },
{ date: new Date("2026-03-05"), type: "VACATION" },
],
calculationRules: DEFAULT_CALCULATION_RULES,
});
// 3 normal days + 2 absent days (counted as working)
expect(result.workingDays).toBe(5);
expect(result.totalHours).toBe(24); // only 3 days actually worked
expect(result.totalProjectCostCents).toBe(2400); // only 3 days charged
expect(result.totalChargeableHours).toBe(40); // all 5 days chargeable
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { findMatchingRule, applyCostEffect } from "../rules/engine.js";
import { DEFAULT_CALCULATION_RULES } from "../rules/default-rules.js";
import type { CalculationRule } from "@planarchy/shared";
const now = new Date();
function makeRule(overrides: Partial<CalculationRule>): CalculationRule {
return {
id: "rule_1",
name: "Test Rule",
description: null,
triggerType: "SICK",
projectId: null,
orderType: null,
costEffect: "ZERO",
costReductionPercent: null,
chargeabilityEffect: "COUNT",
priority: 0,
isActive: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("findMatchingRule", () => {
it("matches by triggerType", () => {
const rules = [makeRule({ triggerType: "SICK" })];
const match = findMatchingRule(rules, "SICK");
expect(match).not.toBeNull();
expect(match!.costEffect).toBe("ZERO");
});
it("returns null when no rules match", () => {
const rules = [makeRule({ triggerType: "SICK" })];
const match = findMatchingRule(rules, "VACATION");
expect(match).toBeNull();
});
it("skips inactive rules", () => {
const rules = [makeRule({ triggerType: "SICK", isActive: false })];
const match = findMatchingRule(rules, "SICK");
expect(match).toBeNull();
});
it("prefers more specific rules (projectId match)", () => {
const global = makeRule({ id: "global", triggerType: "SICK", projectId: null });
const specific = makeRule({ id: "specific", triggerType: "SICK", projectId: "proj_1", costEffect: "CHARGE" });
const match = findMatchingRule([global, specific], "SICK", "proj_1");
expect(match!.rule.id).toBe("specific");
expect(match!.costEffect).toBe("CHARGE");
});
it("does not match project-specific rule to wrong project", () => {
const specific = makeRule({ triggerType: "SICK", projectId: "proj_1" });
const match = findMatchingRule([specific], "SICK", "proj_2");
expect(match).toBeNull();
});
it("prefers higher specificity over higher priority", () => {
const highPriority = makeRule({ id: "hp", triggerType: "SICK", priority: 100 });
const specific = makeRule({ id: "sp", triggerType: "SICK", projectId: "proj_1", priority: 0 });
const match = findMatchingRule([highPriority, specific], "SICK", "proj_1");
expect(match!.rule.id).toBe("sp");
});
it("breaks specificity ties with priority", () => {
const lowP = makeRule({ id: "low", triggerType: "SICK", priority: 5 });
const highP = makeRule({ id: "high", triggerType: "SICK", priority: 10 });
const match = findMatchingRule([lowP, highP], "SICK");
expect(match!.rule.id).toBe("high");
});
it("matches orderType filter", () => {
const rule = makeRule({ triggerType: "VACATION", orderType: "CHARGEABLE" as never, costEffect: "REDUCE", costReductionPercent: 50 });
const match = findMatchingRule([rule], "VACATION", null, "CHARGEABLE");
expect(match).not.toBeNull();
expect(match!.costEffect).toBe("REDUCE");
});
it("does not match wrong orderType", () => {
const rule = makeRule({ triggerType: "VACATION", orderType: "CHARGEABLE" as never });
const match = findMatchingRule([rule], "VACATION", null, "INTERNAL");
expect(match).toBeNull();
});
it("specificity: projectId + orderType > projectId only", () => {
const projOnly = makeRule({ id: "proj", triggerType: "SICK", projectId: "p1" });
const both = makeRule({ id: "both", triggerType: "SICK", projectId: "p1", orderType: "CHARGEABLE" as never, costEffect: "REDUCE" });
const match = findMatchingRule([projOnly, both], "SICK", "p1", "CHARGEABLE");
expect(match!.rule.id).toBe("both");
});
});
describe("applyCostEffect", () => {
it("CHARGE returns full cost", () => {
expect(applyCostEffect(1000, "CHARGE", null)).toBe(1000);
});
it("ZERO returns 0", () => {
expect(applyCostEffect(1000, "ZERO", null)).toBe(0);
});
it("REDUCE applies percentage", () => {
expect(applyCostEffect(1000, "REDUCE", 30)).toBe(700);
});
it("REDUCE with 100% returns 0", () => {
expect(applyCostEffect(1000, "REDUCE", 100)).toBe(0);
});
it("REDUCE with 0% returns full cost", () => {
expect(applyCostEffect(1000, "REDUCE", 0)).toBe(1000);
});
it("REDUCE with null percent returns full cost", () => {
expect(applyCostEffect(1000, "REDUCE", null)).toBe(1000);
});
});
describe("DEFAULT_CALCULATION_RULES", () => {
it("provides vacation, sick, and public holiday rules", () => {
expect(DEFAULT_CALCULATION_RULES).toHaveLength(3);
const triggers = DEFAULT_CALCULATION_RULES.map((r) => r.triggerType);
expect(triggers).toContain("VACATION");
expect(triggers).toContain("SICK");
expect(triggers).toContain("PUBLIC_HOLIDAY");
});
it("vacation: zero cost, count chargeability", () => {
const rule = DEFAULT_CALCULATION_RULES.find((r) => r.triggerType === "VACATION")!;
expect(rule.costEffect).toBe("ZERO");
expect(rule.chargeabilityEffect).toBe("COUNT");
});
it("sick: zero cost, count chargeability", () => {
const rule = DEFAULT_CALCULATION_RULES.find((r) => r.triggerType === "SICK")!;
expect(rule.costEffect).toBe("ZERO");
expect(rule.chargeabilityEffect).toBe("COUNT");
});
it("public holiday: zero cost, skip chargeability", () => {
const rule = DEFAULT_CALCULATION_RULES.find((r) => r.triggerType === "PUBLIC_HOLIDAY")!;
expect(rule.costEffect).toBe("ZERO");
expect(rule.chargeabilityEffect).toBe("SKIP");
});
});