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,5 @@
import { CalculationRulesClient } from "~/components/admin/CalculationRulesClient.js";
export default function CalculationRulesPage() {
return <CalculationRulesClient />;
}
@@ -0,0 +1,356 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
const TRIGGER_TYPES = ["SICK", "VACATION", "PUBLIC_HOLIDAY", "CUSTOM"] as const;
const COST_EFFECTS = ["CHARGE", "ZERO", "REDUCE"] as const;
const CHARGEABILITY_EFFECTS = ["COUNT", "SKIP"] as const;
const ORDER_TYPES = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"] as const;
const TRIGGER_LABELS: Record<string, string> = {
SICK: "Sick Leave",
VACATION: "Vacation",
PUBLIC_HOLIDAY: "Public Holiday",
CUSTOM: "Custom",
};
const COST_LABELS: Record<string, string> = {
CHARGE: "Charge to Project",
ZERO: "No Project Cost",
REDUCE: "Reduced Cost",
};
const CHG_LABELS: Record<string, string> = {
COUNT: "Person Chargeable",
SKIP: "Not Chargeable",
};
type RuleRow = {
id: string;
name: string;
description: string | null;
triggerType: string;
projectId: string | null;
orderType: string | null;
costEffect: string;
costReductionPercent: number | null;
chargeabilityEffect: string;
priority: number;
isActive: boolean;
project?: { id: string; name: string; shortCode: string } | null;
};
type EditingRule = {
id?: string;
name: string;
description: string;
triggerType: string;
projectId: string;
orderType: string;
costEffect: string;
costReductionPercent: number;
chargeabilityEffect: string;
priority: number;
isActive: boolean;
};
const emptyRule: EditingRule = {
name: "",
description: "",
triggerType: "SICK",
projectId: "",
orderType: "",
costEffect: "ZERO",
costReductionPercent: 0,
chargeabilityEffect: "COUNT",
priority: 0,
isActive: true,
};
export function CalculationRulesClient() {
const [editing, setEditing] = useState<EditingRule | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: rules, isLoading } = trpc.calculationRule.list.useQuery();
const createMut = trpc.calculationRule.create.useMutation({
onSuccess: () => { void utils.calculationRule.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const updateMut = trpc.calculationRule.update.useMutation({
onSuccess: () => { void utils.calculationRule.list.invalidate(); setEditing(null); },
onError: (e) => setError(e.message),
});
const deleteMut = trpc.calculationRule.delete.useMutation({
onSuccess: () => { void utils.calculationRule.list.invalidate(); },
onError: (e) => setError(e.message),
});
function openCreate() {
setEditing({ ...emptyRule });
setError(null);
}
function openEdit(r: RuleRow) {
setEditing({
id: r.id,
name: r.name,
description: r.description ?? "",
triggerType: r.triggerType,
projectId: r.projectId ?? "",
orderType: r.orderType ?? "",
costEffect: r.costEffect,
costReductionPercent: r.costReductionPercent ?? 0,
chargeabilityEffect: r.chargeabilityEffect,
priority: r.priority,
isActive: r.isActive,
});
setError(null);
}
function handleSave() {
if (!editing) return;
setError(null);
const payload = {
name: editing.name,
triggerType: editing.triggerType as (typeof TRIGGER_TYPES)[number],
costEffect: editing.costEffect as (typeof COST_EFFECTS)[number],
chargeabilityEffect: editing.chargeabilityEffect as (typeof CHARGEABILITY_EFFECTS)[number],
priority: editing.priority,
isActive: editing.isActive,
...(editing.description ? { description: editing.description } : {}),
...(editing.projectId ? { projectId: editing.projectId } : {}),
...(editing.orderType ? { orderType: editing.orderType } : {}),
...(editing.costEffect === "REDUCE" ? { costReductionPercent: editing.costReductionPercent } : {}),
};
if (editing.id) {
updateMut.mutate({ id: editing.id, ...payload });
} else {
createMut.mutate(payload);
}
}
if (isLoading) return <div className="p-6 text-sm text-gray-500">Loading...</div>;
return (
<div className="mx-auto max-w-5xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Calculation Rules</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure how absences affect project costs and chargeability reporting.
</p>
</div>
<button
onClick={openCreate}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Rule
</button>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* ── Rules Table ── */}
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Trigger</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Cost Effect</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Chargeability</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Scope</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Active</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{(rules ?? []).map((r) => {
const rule = r as unknown as RuleRow;
return (
<tr key={rule.id} className={rule.isActive ? "" : "opacity-50"}>
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{rule.name}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-gray-700">
{TRIGGER_LABELS[rule.triggerType] ?? rule.triggerType}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{COST_LABELS[rule.costEffect] ?? rule.costEffect}
{rule.costEffect === "REDUCE" && rule.costReductionPercent != null && (
<span className="ml-1 text-xs text-gray-400">({rule.costReductionPercent}%)</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{CHG_LABELS[rule.chargeabilityEffect] ?? rule.chargeabilityEffect}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{rule.project ? rule.project.shortCode : rule.orderType ? rule.orderType : "Global"}
</td>
<td className="px-4 py-3 text-sm text-gray-500">{rule.priority}</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${rule.isActive ? "bg-green-500" : "bg-gray-300"}`} />
</td>
<td className="px-4 py-3 text-right text-sm">
<button onClick={() => openEdit(rule)} className="mr-2 text-blue-600 hover:underline dark:text-blue-400">Edit</button>
<button
onClick={() => { if (confirm("Delete this rule?")) deleteMut.mutate({ id: rule.id }); }}
className="text-red-600 hover:underline dark:text-red-400"
>
Delete
</button>
</td>
</tr>
);
})}
{(rules ?? []).length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-gray-500">
No calculation rules configured. Add a rule to control how absences affect costs and chargeability.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* ── Edit/Create Modal ── */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Rule" : "New Rule"}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
rows={2}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type</label>
<select
value={editing.triggerType}
onChange={(e) => setEditing({ ...editing, triggerType: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
{TRIGGER_TYPES.map((t) => <option key={t} value={t}>{TRIGGER_LABELS[t]}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional)</label>
<select
value={editing.orderType}
onChange={(e) => setEditing({ ...editing, orderType: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">All (Global)</option>
{ORDER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect</label>
<select
value={editing.costEffect}
onChange={(e) => setEditing({ ...editing, costEffect: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
{COST_EFFECTS.map((t) => <option key={t} value={t}>{COST_LABELS[t]}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability</label>
<select
value={editing.chargeabilityEffect}
onChange={(e) => setEditing({ ...editing, chargeabilityEffect: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
{CHARGEABILITY_EFFECTS.map((t) => <option key={t} value={t}>{CHG_LABELS[t]}</option>)}
</select>
</div>
</div>
{editing.costEffect === "REDUCE" && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reduction Percent (0-100)
</label>
<input
type="number"
min={0}
max={100}
value={editing.costReductionPercent}
onChange={(e) => setEditing({ ...editing, costReductionPercent: Number(e.target.value) })}
className="w-32 rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<input
type="number"
min={0}
value={editing.priority}
onChange={(e) => setEditing({ ...editing, priority: Number(e.target.value) })}
className="w-32 rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editing.isActive}
onChange={(e) => setEditing({ ...editing, isActive: e.target.checked })}
className="rounded border-gray-300"
/>
Active
</label>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setEditing(null)}
className="rounded-md border px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!editing.name || createMut.isPending || updateMut.isPending}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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";
+286
View File
@@ -0,0 +1,286 @@
# Plan: Calculation Rules Engine
## Anforderungsanalyse
Planarchy berechnet Kosten und Chargeability aktuell hart verdrahtet: Vacation blockiert Stunden komplett, Sick Days werden nicht modelliert, und die Chargeability-Berechnung kennt keine Regeln fuer die Entkopplung von "Person ist chargeable" vs. "Projekt wird belastet".
**Gewuenschtes Verhalten (Beispiele):**
| Szenario | Person chargeable? | Projekt belastet? | Heute |
|----------|-------------------|-------------------|-------|
| Krank + gebucht auf Projekt | Ja | Nein | Nicht modelliert |
| Urlaub + gebucht auf Projekt | Ja | Nein | Urlaub blockiert Stunden komplett |
| Urlaub, nicht gebucht | Ja | — | Urlaub mindert SAH |
| Normal gebucht | Ja | Ja | Korrekt |
**Kernidee:** Ein regelbasiertes System, das pro Tag entscheidet:
1. **costEffect** — Wird der Tag dem Projekt belastet? (`charge` / `zero` / `reduce`)
2. **chargeabilityEffect** — Zaehlt der Tag fuer die Chargeability der Person? (`count` / `skip`)
---
## Betroffene Pakete & Dateien
| Paket | Dateien | Art der Aenderung |
|-------|---------|-----------------|
| shared | `src/types/calculation-rules.ts` | create |
| shared | `src/schemas/calculation-rules.schema.ts` | create |
| shared | `src/types/index.ts` | edit (re-export) |
| db | `prisma/schema.prisma` | edit (neues Model) |
| engine | `src/rules/engine.ts` | create |
| engine | `src/rules/default-rules.ts` | create |
| engine | `src/rules/index.ts` | create |
| engine | `src/allocation/calculator.ts` | edit (Rules-Integration) |
| engine | `src/chargeability/calculator.ts` | edit (Rules-Integration) |
| engine | `src/budget/monitor.ts` | edit (Rules-Integration) |
| engine | `src/__tests__/rules-engine.test.ts` | create |
| engine | `src/__tests__/calculator-rules.test.ts` | create |
| api | `src/router/calculation-rules.ts` | create |
| api | `src/router/index.ts` | edit (Router registrieren) |
| api | `src/router/timeline.ts` | edit (Rules durchreichen) |
| api | `src/router/allocation.ts` | edit (Rules bei Berechnung) |
| web | `src/app/(app)/admin/calculation-rules/page.tsx` | create |
| web | `src/components/admin/CalculationRulesClient.tsx` | create |
| web | `src/components/layout/AppShell.tsx` | edit (Navigation) |
---
## Architektur
### Datenmodell
```prisma
model CalculationRule {
id String @id @default(cuid())
name String // "Sick Leave — Chargeable, No Project Cost"
description String?
// ── Matching ──
triggerType AbsenceTrigger // SICK, VACATION, PUBLIC_HOLIDAY, CUSTOM
// Optional narrowing (null = all):
projectId String?
orderType OrderType? // CHARGEABLE, INTERNAL, INVESTMENT
// ── Effects ──
costEffect CostEffect // CHARGE, ZERO, REDUCE
costReductionPercent Int? // nur bei REDUCE (0-100)
chargeabilityEffect ChargeabilityEffect // COUNT, SKIP
// ── Ordering ──
priority Int @default(0) // hoehere Prioritaet gewinnt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id])
@@map("calculation_rules")
}
enum AbsenceTrigger {
SICK
VACATION
PUBLIC_HOLIDAY
CUSTOM
}
enum CostEffect {
CHARGE // normaler Kostenauftrag ans Projekt
ZERO // keine Kosten ans Projekt
REDUCE // reduzierte Kosten (costReductionPercent)
}
enum ChargeabilityEffect {
COUNT // Person zaehlt als chargeable
SKIP // Person zaehlt nicht (Tag wird aus SAH-Nenner genommen)
}
```
### Rule Matching (Engine)
```
findMatchingRule(day, absenceType, projectId?, orderType?):
candidates = rules.filter(r =>
r.isActive &&
r.triggerType === absenceType &&
(r.projectId === null || r.projectId === projectId) &&
(r.orderType === null || r.orderType === orderType)
)
// Spezifischere Regeln gewinnen, dann priority
return candidates.sort(bySpecificityThenPriority)[0] ?? DEFAULT_RULE
```
**Specificity scoring:**
| Filter combination | Score |
|-------------------|-------|
| projectId + orderType | 3 |
| projectId only | 2 |
| orderType only | 1 |
| global (no filter) | 0 |
### Default Rules (Seed)
| Name | Trigger | Cost | Chargeability |
|------|---------|------|---------------|
| Urlaub — Person chargeable, Projekt nicht belastet | VACATION | ZERO | COUNT |
| Krankheit — Person chargeable, Projekt nicht belastet | SICK | ZERO | COUNT |
| Feiertag — kein Effekt | PUBLIC_HOLIDAY | ZERO | SKIP |
### Integration Points
**1. `calculateAllocation()` (engine/allocation/calculator.ts)**
Aktuell: Vacation-Tag → `effectiveHours = 0`, `costCents = 0`.
Neu: Fuer jeden Tag pruefen ob eine Regel greift. Das DailyBreakdown bekommt zwei neue Felder:
```ts
interface DailyBreakdown {
date: Date;
isWorkday: boolean;
hours: number; // effektive Stunden (wie bisher)
costCents: number; // Kosten fuer das Projekt (regel-gesteuert)
// NEU:
absenceType?: AbsenceTrigger; // was fuer ein Tag ist das?
chargeableHours: number; // Stunden die fuer Chargeability zaehlen
}
```
- Wenn `costEffect === ZERO`: `costCents = 0`, aber `chargeableHours = hoursPerDay`
- Wenn `costEffect === REDUCE`: `costCents = round(normal * (100 - reduction) / 100)`
- Wenn `chargeabilityEffect === COUNT`: `chargeableHours = hoursPerDay` (selbst wenn absent)
- Wenn `chargeabilityEffect === SKIP`: `chargeableHours = 0`
**2. `deriveResourceForecast()` (engine/chargeability/calculator.ts)**
Aktuell: `absence` Ratio ist immer 0 (kein Input).
Neu: Erhaelt `chargeableHours` pro Assignment-Slice statt nur `hoursPerDay * workingDays`.
Die Summe der chargeable Hours wird gegen SAH normiert.
**3. `computeBudgetStatus()` (engine/budget/monitor.ts)**
Aktuell: `dailyCostCents * workingDays` — nimmt an, jeder Tag kostet gleich.
Neu: Bekommt optional ein `adjustedTotalCostCents` pro Allocation, das bereits die Regel-Reduktionen enthaelt. Fallback auf bisherige Berechnung wenn keine Rules aktiv.
**4. Timeline Router (api/router/timeline.ts)**
Laedt Rules einmal, reicht sie an den Calculator durch. Rules werden gecacht (sie aendern sich selten).
---
## Task-Liste (atomare Schritte in Reihenfolge)
### Phase A: Datenmodell & Types
- [x] **A1:** Shared Types erstellen — `AbsenceTrigger`, `CostEffect`, `ChargeabilityEffect`, `CalculationRule` Interface → `packages/shared/src/types/calculation-rules.ts`
- [x] **A2:** Zod Schemas erstellen — `CreateCalculationRuleSchema`, `UpdateCalculationRuleSchema``packages/shared/src/schemas/calculation-rules.schema.ts`
- [x] **A3:** Re-exports in `packages/shared/src/types/index.ts` und `packages/shared/src/schemas/index.ts`
- [x] **A4:** Prisma Schema erweitern — `CalculationRule` Model + Enums → `packages/db/prisma/schema.prisma`
- [x] **A5:** Prisma Client regenerieren (db:push erfordert laufende DB)
### Phase B: Rules Engine (Pure Logic)
- [x] **B1:** Rule Matching Engine — `findMatchingRule()`, Specificity-Scoring → `packages/engine/src/rules/engine.ts`
- [x] **B2:** Default Rules — hartcodierte Fallback-Regeln → `packages/engine/src/rules/default-rules.ts`
- [x] **B3:** Index-Datei → `packages/engine/src/rules/index.ts`
- [x] **B4:** Tests fuer Rule Matching — 20 Tests (Specificity, Priority, Fallback, applyCostEffect) → `packages/engine/src/__tests__/rules-engine.test.ts`
### Phase C: Calculator-Integration
- [x] **C1:** `DailyBreakdown` erweitern — `absenceType`, `chargeableHours``packages/shared/src/types/engine.ts`
- [x] **C2:** `AllocationCalculationInput` erweitern — `calculationRules`, `absenceDays`, `projectId`, `orderType``packages/shared/src/types/engine.ts`
- [x] **C3:** `calculateAllocation()` anpassen — Regel-Lookup pro Tag, neue Felder befuellen → `packages/engine/src/allocation/calculator.ts`
- [x] **C4:** `AllocationCalculationResult` erweitern — `totalChargeableHours`, `totalProjectCostCents` (regelbereinigt)
- [x] **C5:** `deriveResourceForecast()``AssignmentSlice.totalChargeableHours` optional, verwendet statt `hoursPerDay*workingDays``packages/engine/src/chargeability/calculator.ts`
- [x] **C6:** `computeBudgetStatus()` — optional `adjustedTotalCostCents` pro Allocation → `packages/engine/src/budget/monitor.ts`
- [x] **C7:** Tests — 9 Tests (Sick+Booked, Vacation+Booked, Feiertag, Half-Day, REDUCE, Defaults) → `packages/engine/src/__tests__/calculator-rules.test.ts`
- [x] **C8:** Bestehende Calculator-Tests verifiziert — 283/283 bestanden, volle Rueckwaertskompatibilitaet
### Phase D: API Router
- [x] **D1:** `calculation-rules.ts` Router — CRUD (list, getById, getActive, create, update, delete) → `packages/api/src/router/calculation-rules.ts`
- [x] **D2:** Router registrieren → `packages/api/src/router/index.ts`
- [x] **D3:** Timeline Router — `loadCalculationRules()` + `buildAbsenceDays()` Helpers; `updateAllocationInline` + `applyShift` nutzen Rules → `packages/api/src/router/timeline.ts`
- [x] **D4:** Allocation Router — nutzt calculateAllocation nicht direkt (laeuft ueber Timeline); kein Handlungsbedarf
- [x] **D5:** Seed — 3 Default-Regeln einfuegen → `packages/db/src/seed.ts`
### Phase E: Admin UI
- [x] **E1:** `CalculationRulesClient.tsx` — Tabelle mit Rules, Create/Edit Modal → `apps/web/src/components/admin/CalculationRulesClient.tsx`
- [x] **E2:** Page Route → `apps/web/src/app/(app)/admin/calculation-rules/page.tsx`
- [x] **E3:** AppShell Navigation — "Calc. Rules" unter Admin-Bereich → `apps/web/src/components/layout/AppShell.tsx`
### Phase F: Sick Days Pipeline
- [x] **F1:** Timeline Router — `buildAbsenceDays()` laedt SICK/VACATION/PUBLIC_HOLIDAY mit Typ-Tag und reicht an Calculator → `packages/api/src/router/timeline.ts`
- [x] **F2:** Chargeability Report — Vacation-Query um `type`+`isHalfDay` erweitert; per-Monat AbsenceDays gebaut; `calculateAllocation()` mit Rules fuer `totalChargeableHours`; Rules aus DB geladen → `packages/api/src/router/chargeability-report.ts`
---
## Abhaengigkeiten
```
A1 ─── A2 ─── A3 (shared types muessen zuerst stehen)
A4 ─── A5 (Schema vor DB push)
B1 ─── B4 (Engine vor Tests)
B2 ─── B3 (Default Rules vor Index)
A3 ──┐
├── B1 (Types muessen existieren)
A5 ──┘
B4 ──┐
├── C1 → C2 → C3 → C4 (Calculator braucht Types + Engine)
C3 ──── C5 (Chargeability braucht neue DailyBreakdown-Felder)
C3 ──── C6 (Budget braucht adjustierte Kosten)
C3 ──── C7 (Tests nach Implementation)
C4 ──── C8 (Regressionstest)
C4 ──── D1 (Router braucht funktionierende Engine)
D1 ──── D2 (Registrierung nach Router)
D1 ──── D3 (Timeline braucht Router fuer Daten-Zugriff)
D1 ──── D4
D2 ──── E1 → E2 → E3 (UI braucht API)
D3 ──── F1 (Sick Dates Pipeline braucht Rule-aware Timeline)
C5 ──── F2 (Forecast braucht neue chargeableHours)
```
**Parallelisierbar:**
- A1+A2+A3 parallel zu A4 (Types vs. Schema — keine Datei-Ueberschneidung)
- B1+B2 parallel (verschiedene Dateien)
- C5+C6 parallel (verschiedene Calculator-Dateien, nachdem C3 fertig)
- E1+E2+E3 als eigener Stream nachdem D2 fertig
---
## Akzeptanzkriterien
- [ ] `pnpm --filter @planarchy/engine exec vitest run` — alle bestehenden Tests + neue Rules-Tests gruen
- [ ] `pnpm --filter @planarchy/api exec vitest run` — alle Tests gruen
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — 0 neue Errors
- [ ] Ohne konfigurierte Rules: exakt gleiches Verhalten wie heute (Rueckwaertskompatibilitaet)
- [ ] Default-Regeln: Urlaub+Gebucht → Person chargeable, Projekt nicht belastet
- [ ] Default-Regeln: Krank+Gebucht → Person chargeable, Projekt nicht belastet
- [ ] Admin-UI: Rules erstellen, bearbeiten, loeschen, (de-)aktivieren, priorisieren
- [ ] Budget Monitor zeigt regelkonforme Kosten (Sick/Vacation-Tage nicht auf Projekt)
- [ ] Chargeability Report zeigt korrekte Ratios (Sick/Vacation als chargeable)
---
## Risiken & offene Fragen
1. **Rueckwaertskompatibilitaet** — Wenn keine Rules existieren UND kein Seed gelaufen ist, muss der Calculator sich exakt wie heute verhalten. Loesung: `DEFAULT_RULES` als Fallback in der Engine hartcodiert.
2. **Performance** — Rules werden pro Tag pro Allocation evaluiert. Bei 100 Allocations x 250 Tage = 25.000 Evaluierungen. Mitigiert durch: Rules einmal laden + im Memory halten (typisch <10 Regeln), Matching ist O(n) mit n ≈ 5-10.
3. **Sick Days nicht im Allocation-Calculator** — Aktuell kennt `calculateAllocation()` nur `vacationDates`. Es braucht einen neuen Input `sickDates` (oder generischer: `absenceDates` mit Typ-Tag). Die Daten kommen aus der Vacation-Tabelle mit `type = SICK`.
4. **Half-Day Absences** — Das aktuelle System modelliert halbe Tage (`isHalfDay` auf Vacation). Die Rules Engine muss damit umgehen: halber Krankheitstag → halbe Stunden chargeable, halbe Stunden Projektkosten.
5. **Historische Korrektheit** — Aenderungen an Rules wirken sich auf alle zukuenftigen Berechnungen aus. Es gibt kein "Versioning" von Rules. Falls gewuenscht, koennte man `validFrom`/`validTo` Felder ergaenzen — aber erst wenn der Use Case auftritt (YAGNI).
6. **SAH-Integration** — SAH-Calculator hat eigenen Absence-Abzug. Die Rules Engine darf die SAH-Berechnung nicht doppelt reduzieren. Loesung: SAH bleibt wie ist (zaehlt alle Absences ab), die Rules Engine steuert nur die **Zuordnung** der Stunden (chargeable vs. nicht, Projekt vs. nicht).