# 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).