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,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