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,95 @@
import {
CreateCalculationRuleSchema,
UpdateCalculationRuleSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
export const calculationRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.calculationRule.findMany({
orderBy: [{ priority: "desc" }, { name: "asc" }],
include: { project: { select: { id: true, name: true, shortCode: true } } },
});
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const rule = await ctx.db.calculationRule.findUnique({
where: { id: input.id },
include: { project: { select: { id: true, name: true, shortCode: true } } },
});
if (!rule) {
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
}
return rule;
}),
/** Get all active rules (optimized for engine use — no project include) */
getActive: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
}),
create: managerProcedure
.input(CreateCalculationRuleSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.calculationRule.create({
data: {
name: input.name,
triggerType: input.triggerType,
costEffect: input.costEffect,
chargeabilityEffect: input.chargeabilityEffect,
...(input.description !== undefined ? { description: input.description } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.orderType !== undefined ? { orderType: input.orderType as never } : {}),
...(input.costReductionPercent !== undefined ? { costReductionPercent: input.costReductionPercent } : {}),
priority: input.priority,
isActive: input.isActive,
},
});
}),
update: managerProcedure
.input(UpdateCalculationRuleSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.calculationRule.findUnique({ where: { id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
}
// Build update data using exactOptionalPropertyTypes pattern
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.orderType !== undefined) updateData.orderType = data.orderType;
if (data.costEffect !== undefined) updateData.costEffect = data.costEffect;
if (data.costReductionPercent !== undefined) updateData.costReductionPercent = data.costReductionPercent;
if (data.chargeabilityEffect !== undefined) updateData.chargeabilityEffect = data.chargeabilityEffect;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
return ctx.db.calculationRule.update({
where: { id },
data: updateData,
});
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.calculationRule.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
}
await ctx.db.calculationRule.delete({ where: { id: input.id } });
return { success: true };
}),
});
@@ -7,8 +7,11 @@ import {
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
calculateAllocation,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@planarchy/engine";
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
import type { SpainScheduleRule } from "@planarchy/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
@@ -115,7 +118,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
},
}));
// Fetch vacations/absences in the range
// Fetch vacations/absences in the range (including type for rules engine)
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
@@ -127,9 +130,25 @@ export const chargeabilityReportRouter = createTRPCRouter({
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
// Load calculation rules for chargeability adjustments
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// Build per-resource, per-month forecasts
const resourceRows = resources.map((resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
@@ -171,18 +190,65 @@ export const chargeabilityReportRouter = createTRPCRouter({
absenceDays: absenceDates,
});
// Build assignment slices for this month
// Build typed absence days for this resource in this month
const monthAbsenceDays: AbsenceDay[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const absCursor = new Date(vStart);
absCursor.setUTCHours(0, 0, 0, 0);
const absEndNorm = new Date(vEnd);
absEndNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (absCursor <= absEndNorm) {
monthAbsenceDays.push({
date: new Date(absCursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
}
}
// Build assignment slices for this month, using rules to compute chargeable hours
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
// If there are absences and rules, compute rules-adjusted chargeable hours
if (monthAbsenceDays.length > 0) {
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: 0, // we only need hours, not costs
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
absenceDays: monthAbsenceDays,
calculationRules: calcRules,
});
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
});
} else {
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
}
const forecast = deriveResourceForecast({
+2
View File
@@ -1,5 +1,6 @@
import { createTRPCRouter } from "../trpc.js";
import { allocationRouter } from "./allocation.js";
import { calculationRuleRouter } from "./calculation-rules.js";
import { blueprintRouter } from "./blueprint.js";
import { chargeabilityReportRouter } from "./chargeability-report.js";
import { clientRouter } from "./client.js";
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
managementLevel: managementLevelRouter,
rateCard: rateCardRouter,
chargeabilityReport: chargeabilityReportRouter,
calculationRule: calculationRuleRouter,
});
export type AppRouter = typeof appRouter;
+116 -31
View File
@@ -9,7 +9,9 @@ import {
updateAllocationEntry,
} from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine";
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@planarchy/engine";
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
import { VacationType } from "@planarchy/db";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -35,7 +37,7 @@ type ShiftDbClient = Pick<
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "resource"
"demandRequirement" | "assignment" | "resource" | "project"
>;
type TimelineEntriesFilters = {
@@ -43,6 +45,7 @@ type TimelineEntriesFilters = {
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
};
@@ -63,7 +66,7 @@ async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds, chapters, eids } = input;
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids } = input;
// When resource-level filters are active (resourceIds, chapters, or eids),
// resolve matching resource IDs so we can push the filter to the DB query.
@@ -85,6 +88,23 @@ async function loadTimelineEntriesReadModel(
return matching.map((r) => r.id);
})();
const effectiveProjectIds = await (async () => {
if (!clientIds || clientIds.length === 0) return projectIds;
const matchingProjects = await db.project.findMany({
where: { clientId: { in: clientIds } },
select: { id: true },
});
const clientProjectIds = matchingProjects.map((project) => project.id);
if (!projectIds || projectIds.length === 0) {
return clientProjectIds;
}
const allowedIds = new Set(clientProjectIds);
return projectIds.filter((projectId) => allowedIds.has(projectId));
})();
// When filtering by resource (either explicit resourceIds or derived from chapters),
// demands without a resource are excluded.
const excludeDemands = effectiveResourceIds !== undefined;
@@ -97,7 +117,7 @@ async function loadTimelineEntriesReadModel(
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(projectIds ? { projectId: { in: projectIds } } : {}),
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
@@ -108,7 +128,7 @@ async function loadTimelineEntriesReadModel(
startDate: { lte: endDate },
endDate: { gte: startDate },
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
...(projectIds ? { projectId: { in: projectIds } } : {}),
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: TIMELINE_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
@@ -185,6 +205,74 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
};
}
/** Load active calculation rules from DB, falling back to defaults if none configured. */
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
try {
const rules = await db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (rules.length > 0) {
return rules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
return DEFAULT_CALCULATION_RULES;
}
/** Build typed absence days from vacations for a resource in a date range. */
async function buildAbsenceDays(
db: PrismaClient,
resourceId: string,
startDate: Date,
endDate: Date,
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
const absenceDays: AbsenceDay[] = [];
const legacyVacationDates: Date[] = [];
try {
const vacations = await db.vacation.findMany({
where: {
resourceId,
status: "APPROVED",
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
for (const v of vacations) {
const cur = new Date(v.startDate);
cur.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
// Map Prisma VacationType to AbsenceTrigger
const triggerType = v.type === VacationType.SICK ? "SICK" as const
: v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cur <= vEnd) {
absenceDays.push({
date: new Date(cur),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
// Also populate legacy vacation dates for backward compat
if (triggerType === "VACATION") {
legacyVacationDates.push(new Date(cur));
}
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet
}
return { absenceDays, legacyVacationDates };
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
@@ -197,6 +285,7 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
@@ -214,6 +303,7 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
@@ -345,31 +435,11 @@ export const timelineRouter = createTRPCRouter({
// Load recurrence from merged metadata
const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined);
// Load approved vacations for recalculation (graceful fallback if table not yet migrated)
const vacationDates: Date[] = [];
try {
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: resolved.resourceId,
status: "APPROVED",
startDate: { lte: newEndDate },
endDate: { gte: newStartDate },
},
select: { startDate: true, endDate: true },
});
for (const v of vacations) {
const cur = new Date(v.startDate);
cur.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
while (cur <= vEnd) {
vacationDates.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet — proceed without vacation adjustment
}
// Load typed absences + calculation rules for rules-aware cost computation
const [absenceData, calculationRules] = await Promise.all([
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
loadCalculationRules(ctx.db as PrismaClient),
]);
newDailyCostCents = calculateAllocation({
lcrCents: existingResource.lcrCents,
@@ -379,7 +449,9 @@ export const timelineRouter = createTRPCRouter({
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
vacationDates,
vacationDates: absenceData.legacyVacationDates,
absenceDays: absenceData.absenceDays,
calculationRules,
}).dailyCostCents;
}
@@ -500,6 +572,9 @@ export const timelineRouter = createTRPCRouter({
});
}
// Pre-load calculation rules for cost recalculation
const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);
// Apply shift in a transaction
const updatedProject = await ctx.db.$transaction(async (tx) => {
// Update project dates
@@ -523,6 +598,13 @@ export const timelineRouter = createTRPCRouter({
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const shiftAbsenceData = await buildAbsenceDays(
ctx.db as PrismaClient,
assignment.resourceId!,
newStartDate,
newEndDate,
);
const newDailyCost = calculateAllocation({
lcrCents: assignment.resource!.lcrCents,
hoursPerDay: assignment.hoursPerDay,
@@ -531,6 +613,9 @@ export const timelineRouter = createTRPCRouter({
availability:
assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
includeSaturday,
vacationDates: shiftAbsenceData.legacyVacationDates,
absenceDays: shiftAbsenceData.absenceDays,
calculationRules: shiftRules,
}).dailyCostCents;
await updateAssignment(
+50
View File
@@ -135,6 +135,24 @@ enum DispoImportSourceKind {
ROSTER
}
enum AbsenceTrigger {
SICK
VACATION
PUBLIC_HOLIDAY
CUSTOM
}
enum CostEffect {
CHARGE
ZERO
REDUCE
}
enum ChargeabilityEffect {
COUNT
SKIP
}
enum DispoStagedRecordType {
RESOURCE
CLIENT
@@ -805,6 +823,7 @@ model Project {
demandRequirements DemandRequirement[]
assignments Assignment[]
estimates Estimate[]
calculationRules CalculationRule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1315,6 +1334,37 @@ model SystemSettings {
@@map("system_settings")
}
// ─── Calculation Rules ────────────────────────────────────────────────────────
model CalculationRule {
id String @id @default(cuid())
name String
description String?
// ── Matching ──
triggerType AbsenceTrigger
projectId String?
orderType OrderType?
// ── Effects ──
costEffect CostEffect
costReductionPercent Int? // only for REDUCE (0-100)
chargeabilityEffect ChargeabilityEffect
// ── Ordering ──
priority Int @default(0)
isActive Boolean @default(true)
project Project? @relation(fields: [projectId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([triggerType, isActive])
@@index([projectId])
@@map("calculation_rules")
}
// ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog {
+35
View File
@@ -293,6 +293,7 @@ async function main() {
await prisma.resourceRole.deleteMany({});
await prisma.vacation.deleteMany({});
await prisma.vacationEntitlement.deleteMany({});
await prisma.calculationRule.deleteMany({});
await prisma.project.deleteMany({});
await prisma.resource.deleteMany({});
await prisma.role.deleteMany({});
@@ -1215,6 +1216,40 @@ async function main() {
}
console.warn(`Vacations: ${vacationCount} created`);
// ── Calculation Rules (default set) ──────────────────────────────────────────
await prisma.calculationRule.createMany({
data: [
{
name: "Urlaub — Person chargeable, Projekt nicht belastet",
description: "Vacation days count toward chargeability but are not charged to the project.",
triggerType: "VACATION",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
priority: 0,
isActive: true,
},
{
name: "Krankheit — Person chargeable, Projekt nicht belastet",
description: "Sick days count toward chargeability but are not charged to the project.",
triggerType: "SICK",
costEffect: "ZERO",
chargeabilityEffect: "COUNT",
priority: 0,
isActive: true,
},
{
name: "Feiertag — kein Effekt",
description: "Public holidays are neither chargeable nor charged to projects.",
triggerType: "PUBLIC_HOLIDAY",
costEffect: "ZERO",
chargeabilityEffect: "SKIP",
priority: 0,
isActive: true,
},
],
});
console.warn("Calculation rules: 3 default rules created");
console.warn("Seed complete!");
}
@@ -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");
});
});
+100 -9
View File
@@ -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 } : {}),
};
}
+6 -4
View File
@@ -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;
}
+1
View File
@@ -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,
},
];
+91
View File
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
export { findMatchingRule, applyCostEffect } from "./engine.js";
export type { RuleMatch } from "./engine.js";
export { DEFAULT_CALCULATION_RULES } from "./default-rules.js";
@@ -0,0 +1,22 @@
import { z } from "zod";
export const AbsenceTriggerEnum = z.enum(["SICK", "VACATION", "PUBLIC_HOLIDAY", "CUSTOM"]);
export const CostEffectEnum = z.enum(["CHARGE", "ZERO", "REDUCE"]);
export const ChargeabilityEffectEnum = z.enum(["COUNT", "SKIP"]);
export const CreateCalculationRuleSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
triggerType: AbsenceTriggerEnum,
projectId: z.string().optional(),
orderType: z.string().optional(),
costEffect: CostEffectEnum,
costReductionPercent: z.number().int().min(0).max(100).optional(),
chargeabilityEffect: ChargeabilityEffectEnum,
priority: z.number().int().min(0).max(1000).default(0),
isActive: z.boolean().default(true),
});
export const UpdateCalculationRuleSchema = CreateCalculationRuleSchema.partial().extend({
id: z.string(),
});
+1
View File
@@ -13,3 +13,4 @@ export * from "./client.schema.js";
export * from "./management-level.schema.js";
export * from "./rate-card.schema.js";
export * from "./dispo-import.schema.js";
export * from "./calculation-rules.schema.js";
@@ -0,0 +1,32 @@
// ─── Calculation Rules ────────────────────────────────────────────────────────
// Admin-configurable rules that decouple cost attribution from chargeability.
// Example: "Sick person = still chargeable, but NOT charged to project."
export type AbsenceTrigger = "SICK" | "VACATION" | "PUBLIC_HOLIDAY" | "CUSTOM";
export type CostEffect = "CHARGE" | "ZERO" | "REDUCE";
export type ChargeabilityEffect = "COUNT" | "SKIP";
export interface CalculationRule {
id: string;
name: string;
description: string | null;
// ── Matching ──
triggerType: AbsenceTrigger;
projectId: string | null; // null = all projects
orderType: string | null; // null = all order types
// ── Effects ──
costEffect: CostEffect;
costReductionPercent: number | null; // only for REDUCE (0-100)
chargeabilityEffect: ChargeabilityEffect;
// ── Ordering ──
priority: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
+22
View File
@@ -39,6 +39,12 @@ export interface ConflictDetail {
message: string;
}
export interface AbsenceDay {
date: Date;
type: import("./calculation-rules.js").AbsenceTrigger;
isHalfDay?: boolean;
}
export interface AllocationCalculationInput {
lcrCents: number;
hoursPerDay: number;
@@ -51,6 +57,14 @@ export interface AllocationCalculationInput {
recurrence?: import("./allocation.js").RecurrencePattern;
/** APPROVED vacation dates — these days are blocked regardless of other settings */
vacationDates?: Date[];
/** Typed absence days (vacation, sick, public holiday) — used by the rules engine */
absenceDays?: AbsenceDay[];
/** Calculation rules — when provided, absence days are evaluated against these rules */
calculationRules?: import("./calculation-rules.js").CalculationRule[];
/** Order type of the project — used for rule matching */
orderType?: string;
/** Project ID — used for rule matching */
projectId?: string;
}
export interface AllocationCalculationResult {
@@ -59,6 +73,10 @@ export interface AllocationCalculationResult {
totalCostCents: number;
dailyCostCents: number;
dailyBreakdown: DailyBreakdown[];
/** Total hours counting toward chargeability (rules-adjusted) */
totalChargeableHours?: number;
/** Cost after rule adjustments (e.g. sick days zeroed out) */
totalProjectCostCents?: number;
}
export interface DailyBreakdown {
@@ -66,6 +84,10 @@ export interface DailyBreakdown {
isWorkday: boolean;
hours: number;
costCents: number;
/** Absence type for this day (if any rule matched) */
absenceType?: import("./calculation-rules.js").AbsenceTrigger;
/** Hours that count toward chargeability (may differ from hours when rules apply) */
chargeableHours?: number;
}
export interface BudgetStatus {
+1
View File
@@ -18,3 +18,4 @@ export * from "./utilization-category.js";
export * from "./client.js";
export * from "./management-level.js";
export * from "./dispo-import.js";
export * from "./calculation-rules.js";