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");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import type {
|
||||
AbsenceDay,
|
||||
AllocationCalculationInput,
|
||||
AllocationCalculationResult,
|
||||
DailyBreakdown,
|
||||
WeekdayAvailability,
|
||||
} from "@planarchy/shared";
|
||||
import type { AbsenceTrigger } from "@planarchy/shared";
|
||||
import { getRecurringHoursForDay } from "./recurrence.js";
|
||||
import { findMatchingRule, applyCostEffect } from "../rules/engine.js";
|
||||
|
||||
/** Day-of-week index → availability key */
|
||||
const DOW_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
@@ -64,10 +67,16 @@ export function countWorkingDays(
|
||||
* Core allocation calculator: given hours/day, LCR, and date range,
|
||||
* computes total hours, total cost, and daily breakdown.
|
||||
*
|
||||
* When calculationRules + absenceDays are provided, the rules engine
|
||||
* determines per-day cost attribution and chargeability effects.
|
||||
*
|
||||
* Monetary values always in integer cents.
|
||||
*/
|
||||
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
|
||||
const { lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday, recurrence, vacationDates } = input;
|
||||
const {
|
||||
lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday,
|
||||
recurrence, vacationDates, absenceDays, calculationRules, orderType, projectId,
|
||||
} = input;
|
||||
|
||||
// When includeSaturday is not explicitly true, zero out saturday availability
|
||||
const effectiveAvailability: WeekdayAvailability = includeSaturday
|
||||
@@ -83,6 +92,16 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
}),
|
||||
);
|
||||
|
||||
// Pre-compute typed absence day lookup (date key → AbsenceDay)
|
||||
const absenceDayMap = new Map<string, AbsenceDay>();
|
||||
for (const ad of absenceDays ?? []) {
|
||||
const copy = new Date(ad.date);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
absenceDayMap.set(copy.toISOString().split("T")[0]!, ad);
|
||||
}
|
||||
|
||||
const hasRules = calculationRules && calculationRules.length > 0;
|
||||
|
||||
const allocationStart = new Date(startDate);
|
||||
allocationStart.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -94,49 +113,120 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
|
||||
let workingDays = 0;
|
||||
let totalHours = 0;
|
||||
let totalChargeableHours = 0;
|
||||
let totalProjectCostCents = 0;
|
||||
|
||||
while (current <= end) {
|
||||
const dateKey = current.toISOString().split("T")[0]!;
|
||||
const isVacation = vacationDateSet.has(dateKey);
|
||||
const absenceDay = absenceDayMap.get(dateKey);
|
||||
|
||||
let effectiveHours: number;
|
||||
let dayIsWorkday: boolean;
|
||||
let absenceType: AbsenceTrigger | undefined;
|
||||
let chargeableHours: number | undefined;
|
||||
let projectCostCents: number;
|
||||
|
||||
if (isVacation) {
|
||||
// Vacation always blocks the day
|
||||
// Determine if this is an absence day (from typed absenceDays or legacy vacationDates)
|
||||
const isAbsent = isVacation || !!absenceDay;
|
||||
if (absenceDay) {
|
||||
absenceType = absenceDay.type;
|
||||
} else if (isVacation) {
|
||||
absenceType = "VACATION";
|
||||
}
|
||||
|
||||
if (isAbsent && hasRules && absenceType) {
|
||||
// ── Rules-based absence handling ──
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
|
||||
if (!dayIsWorkday) {
|
||||
// Weekend/non-working day — no effect regardless of absence
|
||||
effectiveHours = 0;
|
||||
chargeableHours = 0;
|
||||
projectCostCents = 0;
|
||||
} else {
|
||||
const normalHours = Math.min(hoursPerDay, availableHours);
|
||||
const halfDayFactor = absenceDay?.isHalfDay ? 0.5 : 1;
|
||||
const absentHours = normalHours * halfDayFactor;
|
||||
const workedHours = normalHours - absentHours;
|
||||
|
||||
// The person does NOT work the absent portion
|
||||
effectiveHours = workedHours;
|
||||
|
||||
const match = findMatchingRule(calculationRules!, absenceType, projectId, orderType);
|
||||
if (match) {
|
||||
// Cost effect: how much does the project pay?
|
||||
const normalCostCents = Math.round(absentHours * lcrCents);
|
||||
const absentProjectCost = applyCostEffect(normalCostCents, match.costEffect, match.costReductionPercent);
|
||||
const workedCostCents = Math.round(workedHours * lcrCents);
|
||||
projectCostCents = workedCostCents + absentProjectCost;
|
||||
|
||||
// Chargeability effect: does the person count as chargeable?
|
||||
if (match.chargeabilityEffect === "COUNT") {
|
||||
chargeableHours = normalHours; // full hours count toward chargeability
|
||||
} else {
|
||||
chargeableHours = workedHours; // only worked portion counts
|
||||
}
|
||||
} else {
|
||||
// No matching rule — legacy behavior: block absent hours
|
||||
effectiveHours = workedHours;
|
||||
projectCostCents = Math.round(workedHours * lcrCents);
|
||||
chargeableHours = workedHours;
|
||||
}
|
||||
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
} else if (isVacation && !hasRules) {
|
||||
// ── Legacy behavior: vacation blocks the day entirely ──
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
projectCostCents = 0;
|
||||
} else if (recurrence) {
|
||||
// Recurrence pattern — may override hoursPerDay or skip the day entirely
|
||||
const recurHours = getRecurringHoursForDay(current, recurrence, hoursPerDay, allocationStart);
|
||||
if (recurHours === 0) {
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
projectCostCents = 0;
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(recurHours, availableHours) : 0;
|
||||
projectCostCents = Math.round(effectiveHours * lcrCents);
|
||||
}
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(hoursPerDay, availableHours) : 0;
|
||||
projectCostCents = Math.round(effectiveHours * lcrCents);
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
}
|
||||
|
||||
// Cost = hours × lcrCents (already in cents-per-hour)
|
||||
const dayCostCents = Math.round(effectiveHours * lcrCents);
|
||||
// costCents on DailyBreakdown = project cost (rule-adjusted)
|
||||
const dayCostCents = projectCostCents;
|
||||
|
||||
breakdown.push({
|
||||
date: new Date(current),
|
||||
isWorkday: dayIsWorkday,
|
||||
hours: effectiveHours,
|
||||
costCents: dayCostCents,
|
||||
...(absenceType ? { absenceType } : {}),
|
||||
...(chargeableHours !== undefined ? { chargeableHours } : {}),
|
||||
});
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
totalChargeableHours += chargeableHours ?? effectiveHours;
|
||||
totalProjectCostCents += dayCostCents;
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
@@ -150,6 +240,7 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
totalCostCents,
|
||||
dailyCostCents,
|
||||
dailyBreakdown: breakdown,
|
||||
...(hasRules ? { totalChargeableHours, totalProjectCostCents } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import { BUDGET_WARNING_THRESHOLDS } from "@planarchy/shared";
|
||||
export function computeBudgetStatus(
|
||||
budgetCents: number,
|
||||
winProbability: number,
|
||||
allocations: Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
|
||||
allocations: (Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"> & {
|
||||
/** When provided (from rules engine), used instead of dailyCostCents * days */
|
||||
adjustedTotalCostCents?: number;
|
||||
})[],
|
||||
projectStartDate: Date,
|
||||
projectEndDate: Date,
|
||||
): BudgetStatus {
|
||||
@@ -19,11 +22,10 @@ export function computeBudgetStatus(
|
||||
let proposedCents = 0;
|
||||
|
||||
for (const alloc of allocations) {
|
||||
const days = countWorkingDaysInRange(
|
||||
const totalCents = alloc.adjustedTotalCostCents ?? (alloc.dailyCostCents * countWorkingDaysInRange(
|
||||
new Date(alloc.startDate),
|
||||
new Date(alloc.endDate),
|
||||
);
|
||||
const totalCents = alloc.dailyCostCents * days;
|
||||
));
|
||||
|
||||
if (activeStatuses.has(alloc.status)) {
|
||||
confirmedCents += totalCents;
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface AssignmentSlice {
|
||||
workingDays: number;
|
||||
/** Utilization category code (e.g. "Chg", "BD", "MD&I", "M&O", "PD&R"). */
|
||||
categoryCode: string;
|
||||
/** Override total hours for this slice (e.g. when rules adjust chargeable hours).
|
||||
* When set, used instead of hoursPerDay * workingDays. */
|
||||
totalChargeableHours?: number;
|
||||
}
|
||||
|
||||
export interface ResourceForecast {
|
||||
@@ -58,10 +61,10 @@ export function deriveResourceForecast(input: ResourceForecastInput): ResourceFo
|
||||
return { chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 };
|
||||
}
|
||||
|
||||
// Sum hours per category
|
||||
// Sum hours per category (use totalChargeableHours when available for rules-adjusted values)
|
||||
const categoryHours: Record<string, number> = {};
|
||||
for (const a of assignments) {
|
||||
const hours = a.hoursPerDay * a.workingDays;
|
||||
const hours = a.totalChargeableHours ?? (a.hoursPerDay * a.workingDays);
|
||||
const key = a.categoryCode.toLowerCase();
|
||||
categoryHours[key] = (categoryHours[key] ?? 0) + hours;
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./shift/index.js";
|
||||
export * from "./vacation/utils.js";
|
||||
export * from "./sah/index.js";
|
||||
export * from "./chargeability/index.js";
|
||||
export * from "./rules/index.js";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Default calculation rules — used as fallback when no DB rules are configured.
|
||||
*
|
||||
* These encode the business defaults:
|
||||
* - Vacation: person is chargeable, project is NOT charged
|
||||
* - Sick: person is chargeable, project is NOT charged
|
||||
* - Public holiday: no chargeability effect, no project cost
|
||||
*/
|
||||
|
||||
import type { CalculationRule } from "@planarchy/shared";
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export const DEFAULT_CALCULATION_RULES: CalculationRule[] = [
|
||||
{
|
||||
id: "default_vacation",
|
||||
name: "Urlaub — Person chargeable, Projekt nicht belastet",
|
||||
description: "Vacation days count toward chargeability but are not charged to the project.",
|
||||
triggerType: "VACATION",
|
||||
projectId: null,
|
||||
orderType: null,
|
||||
costEffect: "ZERO",
|
||||
costReductionPercent: null,
|
||||
chargeabilityEffect: "COUNT",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "default_sick",
|
||||
name: "Krankheit — Person chargeable, Projekt nicht belastet",
|
||||
description: "Sick days count toward chargeability but are not charged to the project.",
|
||||
triggerType: "SICK",
|
||||
projectId: null,
|
||||
orderType: null,
|
||||
costEffect: "ZERO",
|
||||
costReductionPercent: null,
|
||||
chargeabilityEffect: "COUNT",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "default_public_holiday",
|
||||
name: "Feiertag — kein Effekt",
|
||||
description: "Public holidays are neither chargeable nor charged to projects.",
|
||||
triggerType: "PUBLIC_HOLIDAY",
|
||||
projectId: null,
|
||||
orderType: null,
|
||||
costEffect: "ZERO",
|
||||
costReductionPercent: null,
|
||||
chargeabilityEffect: "SKIP",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { findMatchingRule, applyCostEffect } from "./engine.js";
|
||||
export type { RuleMatch } from "./engine.js";
|
||||
export { DEFAULT_CALCULATION_RULES } from "./default-rules.js";
|
||||
Reference in New Issue
Block a user