Files
CapaKraken/plan.md
T
Hartmut 368fd6d7ad 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>
2026-03-15 09:29:12 +01:00

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:

  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

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, 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

  • A1: Shared Types erstellen — AbsenceTrigger, CostEffect, ChargeabilityEffect, CalculationRule Interface → packages/shared/src/types/calculation-rules.ts
  • A2: Zod Schemas erstellen — CreateCalculationRuleSchema, UpdateCalculationRuleSchemapackages/shared/src/schemas/calculation-rules.schema.ts
  • A3: Re-exports in packages/shared/src/types/index.ts und packages/shared/src/schemas/index.ts
  • A4: Prisma Schema erweitern — CalculationRule Model + 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: DailyBreakdown erweitern — absenceType, chargeableHourspackages/shared/src/types/engine.ts
  • C2: AllocationCalculationInput erweitern — calculationRules, absenceDays, projectId, orderTypepackages/shared/src/types/engine.ts
  • C3: calculateAllocation() anpassen — Regel-Lookup pro Tag, neue Felder befuellen → packages/engine/src/allocation/calculator.ts
  • C4: AllocationCalculationResult erweitern — totalChargeableHours, totalProjectCostCents (regelbereinigt)
  • C5: deriveResourceForecast()AssignmentSlice.totalChargeableHours optional, verwendet statt hoursPerDay*workingDayspackages/engine/src/chargeability/calculator.ts
  • C6: computeBudgetStatus() — optional adjustedTotalCostCents pro 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.ts Router — 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 + applyShift nutzen 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+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).