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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user