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>
13 KiB
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:
- costEffect — Wird der Tag dem Projekt belastet? (
charge/zero/reduce) - 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
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:
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, aberchargeableHours = 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
- A1: Shared Types erstellen —
AbsenceTrigger,CostEffect,ChargeabilityEffect,CalculationRuleInterface →packages/shared/src/types/calculation-rules.ts - A2: Zod Schemas erstellen —
CreateCalculationRuleSchema,UpdateCalculationRuleSchema→packages/shared/src/schemas/calculation-rules.schema.ts - A3: Re-exports in
packages/shared/src/types/index.tsundpackages/shared/src/schemas/index.ts - A4: Prisma Schema erweitern —
CalculationRuleModel + Enums →packages/db/prisma/schema.prisma - A5: Prisma Client regenerieren (db:push erfordert laufende DB)
Phase B: Rules Engine (Pure Logic)
- B1: Rule Matching Engine —
findMatchingRule(), Specificity-Scoring →packages/engine/src/rules/engine.ts - B2: Default Rules — hartcodierte Fallback-Regeln →
packages/engine/src/rules/default-rules.ts - B3: Index-Datei →
packages/engine/src/rules/index.ts - B4: Tests fuer Rule Matching — 20 Tests (Specificity, Priority, Fallback, applyCostEffect) →
packages/engine/src/__tests__/rules-engine.test.ts
Phase C: Calculator-Integration
- C1:
DailyBreakdownerweitern —absenceType,chargeableHours→packages/shared/src/types/engine.ts - C2:
AllocationCalculationInputerweitern —calculationRules,absenceDays,projectId,orderType→packages/shared/src/types/engine.ts - C3:
calculateAllocation()anpassen — Regel-Lookup pro Tag, neue Felder befuellen →packages/engine/src/allocation/calculator.ts - C4:
AllocationCalculationResulterweitern —totalChargeableHours,totalProjectCostCents(regelbereinigt) - C5:
deriveResourceForecast()—AssignmentSlice.totalChargeableHoursoptional, verwendet statthoursPerDay*workingDays→packages/engine/src/chargeability/calculator.ts - C6:
computeBudgetStatus()— optionaladjustedTotalCostCentspro Allocation →packages/engine/src/budget/monitor.ts - C7: Tests — 9 Tests (Sick+Booked, Vacation+Booked, Feiertag, Half-Day, REDUCE, Defaults) →
packages/engine/src/__tests__/calculator-rules.test.ts - C8: Bestehende Calculator-Tests verifiziert — 283/283 bestanden, volle Rueckwaertskompatibilitaet
Phase D: API Router
- D1:
calculation-rules.tsRouter — CRUD (list, getById, getActive, create, update, delete) →packages/api/src/router/calculation-rules.ts - D2: Router registrieren →
packages/api/src/router/index.ts - D3: Timeline Router —
loadCalculationRules()+buildAbsenceDays()Helpers;updateAllocationInline+applyShiftnutzen Rules →packages/api/src/router/timeline.ts - D4: Allocation Router — nutzt calculateAllocation nicht direkt (laeuft ueber Timeline); kein Handlungsbedarf
- D5: Seed — 3 Default-Regeln einfuegen →
packages/db/src/seed.ts
Phase E: Admin UI
- E1:
CalculationRulesClient.tsx— Tabelle mit Rules, Create/Edit Modal →apps/web/src/components/admin/CalculationRulesClient.tsx - E2: Page Route →
apps/web/src/app/(app)/admin/calculation-rules/page.tsx - E3: AppShell Navigation — "Calc. Rules" unter Admin-Bereich →
apps/web/src/components/layout/AppShell.tsx
Phase F: Sick Days Pipeline
- F1: Timeline Router —
buildAbsenceDays()laedt SICK/VACATION/PUBLIC_HOLIDAY mit Typ-Tag und reicht an Calculator →packages/api/src/router/timeline.ts - F2: Chargeability Report — Vacation-Query um
type+isHalfDayerweitert; per-Monat AbsenceDays gebaut;calculateAllocation()mit Rules fuertotalChargeableHours; 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 gruenpnpm --filter @planarchy/api exec vitest run— alle Tests gruenpnpm --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
-
Rueckwaertskompatibilitaet — Wenn keine Rules existieren UND kein Seed gelaufen ist, muss der Calculator sich exakt wie heute verhalten. Loesung:
DEFAULT_RULESals Fallback in der Engine hartcodiert. -
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.
-
Sick Days nicht im Allocation-Calculator — Aktuell kennt
calculateAllocation()nurvacationDates. Es braucht einen neuen InputsickDates(oder generischer:absenceDatesmit Typ-Tag). Die Daten kommen aus der Vacation-Tabelle mittype = SICK. -
Half-Day Absences — Das aktuelle System modelliert halbe Tage (
isHalfDayauf Vacation). Die Rules Engine muss damit umgehen: halber Krankheitstag → halbe Stunden chargeable, halbe Stunden Projektkosten. -
Historische Korrektheit — Aenderungen an Rules wirken sich auf alle zukuenftigen Berechnungen aus. Es gibt kein "Versioning" von Rules. Falls gewuenscht, koennte man
validFrom/validToFelder ergaenzen — aber erst wenn der Use Case auftritt (YAGNI). -
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).