diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
index 5acd463..b1076b7 100644
--- a/REQUIREMENTS.md
+++ b/REQUIREMENTS.md
@@ -125,8 +125,10 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|---|---|---|---|---|
| FR-REC-1 | Bewerberpipeline: Interessent → Bewerbung → Erstkontakt → Vorstellung → Unterlagen → Angebot → Einstellung → geplanter Eintritt [Blueprint 3.2] | Sascha | Bewerberdatensatz mit Statuswechsel | ⬜ |
| FR-REC-2 | Werbung/Empfehlung: Kanäle Indeed, Facebook, Instagram, Mitarbeiterempfehlung erfassbar [omsorg.md] | Sascha | Herkunft je Bewerber taggable, auswertbar | 🔶 (`pages/werben.php`/`actions/submit-werben.php` erfasst Mitarbeiterempfehlungen bereits; Kanaltracking für externe Werbung fehlt) |
-| FR-REC-3 | Einrichtungsakquise: Lead anlegen → Ansprechpartner erfassen → Kontakt → Gesprächsnotiz → Ergebnis → automatische Wiedervorlage nach 14 Tagen [Blueprint 3.5] | Sascha | Wiedervorlage wird ohne manuellen Trigger vom System erzeugt | ⬜ |
+| FR-REC-3 | Einrichtungsakquise: Lead anlegen → Ansprechpartner erfassen → Kontakt → Gesprächsnotiz → Ergebnis → automatische Wiedervorlage nach gestaffeltem Akquise-Rhythmus [Blueprint 3.5]. Empfohlener Rhythmus (fachliche Vorgabe, nicht Blueprint-Wortlaut): Tag 1 Erstanruf, Tag 1 E-Mail mit Kurzvorstellung, nach 10–14 Tagen zweiter Anruf, nach 4–6 Wochen erneuter Kontakt falls kein Bedarf bestand, danach regelmäßige Wiedervorlagen zum freundlichen In-Erinnerung-Bleiben | Sascha | Wiedervorlage wird ohne manuellen Trigger vom System erzeugt, mit dem zur jeweiligen Akquise-Stufe passenden Fristvorschlag (10–14 Tage nach Erstkontakt, 4–6 Wochen nach zweitem Kontakt ohne Bedarf, danach wiederkehrend) statt einer einzigen festen Frist | ⬜ |
| FR-REC-4 | Sascha hat keinen Zugriff auf Rechnungen, Zahlungen, Gewinn, Margen, Controlling [Blueprint 6.2] | System | Rollenprüfung blockiert Zugriff serverseitig | ⬜ |
+| FR-REC-5 | Kaltakquise vollständig im CRM abgebildet statt einer losen Telefonnummernliste: Lead anlegen, Ansprechpartner speichern, Telefonat dokumentieren (Gesprächsprotokoll je Kontaktversuch), automatische Wiedervorlage je Akquise-Stufe (siehe FR-REC-3), E-Mail-Versand aus dem CRM heraus (z. B. Kurzvorstellung am Erstkontakttag), Angebotsstatus je Lead | Sascha | Jeder Akquise-Schritt (Anruf, E-Mail, Gesprächsnotiz, Status-/Angebotswechsel) ist am Lead-Datensatz nachvollziehbar, ohne Werkzeug außerhalb der Software | ⬜ |
+| FR-REC-6 | Akquise-Erfolgsmessung: Abschlussquote je Mitarbeiter, Dashboard mit täglichen Anrufen, Terminen und gewonnenen Kunden [omsorg.md] | Sascha, Sabina, Malik | Kennzahlen werden aus den dokumentierten Akquise-Aktivitäten (FR-REC-5) berechnet, nicht manuell gepflegt | ⬜ |
### 4.8 Controlling
@@ -258,7 +260,7 @@ Für zukünftige Büromitarbeiter: Rolle als Vorlage, zusätzlich granular je Mo
| Phase 2 – Stammdaten | FR-MA-1..5, FR-EIN-1..5 | Blockiert durch Phase 1 |
| Phase 3 – Operatives Geschäft | FR-EM-1..5 | Blockiert durch Phase 1/2 |
| Phase 4 – Zeit und Abrechnung | FR-ZE-1..4, FR-RE-1..4 | Teilbasis vorhanden (Connect-Upload), aber ohne Core nicht rechnungsfähig |
-| Phase 5 – Recruiting und CRM | FR-REC-1..4 | Teilbasis vorhanden (Werben), CRM-Pipeline fehlt |
+| Phase 5 – Recruiting und CRM | FR-REC-1..6 | Teilbasis vorhanden (Werben), CRM-Pipeline fehlt |
| Phase 6 – OMSORG Connect | FR-CON-1..4 | 🔶 größtenteils vorhanden, Fahrtenbuch + Sync offen |
| Phase 7 – Dashboard und Controlling | FR-DASH-1..4, FR-CTL-1..3, FR-OUT-1 | Teilbasis (`omsorgapp` Dashboard-Ansätze) vorhanden |
diff --git a/omsorgCore/CLAUDE.md b/omsorgCore/CLAUDE.md
index 0ff6791..c364fd1 100644
--- a/omsorgCore/CLAUDE.md
+++ b/omsorgCore/CLAUDE.md
@@ -68,6 +68,8 @@ omsorgCore/
Rolle liefert Standard-Rechte (`RolePermission`: Modul × Aktion), ein individueller `UserPermissionOverride` (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe `PermissionService.HasPermissionAsync` (`src/OmsorgCore.Application/Services/PermissionService.cs`). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.
+**`ModuleType.EmployeeFacilityDistances`** (additiv hinzugefügt 2026-08-10) gated ausschließlich `MyFacilityDistancesController` (Selbstbedienung, siehe "Konditionen einer Einrichtung" unten) — bewusst getrennt von `Facilities`, damit der Außendienst seine eigene Fahrtstrecke pflegen kann, ohne Konditionen/CRM-Daten von Einrichtungen zu sehen. Nur `Außendienst` bekommt es im Basis-Seed (`Create`/`View`/`Edit`, Scope `Own`, siehe `DbSeeder.SeedBaseRolesAsync`); Büro-Rollen verwalten Distanzen weiterhin über `EmployeeFacilityDistancesController`/`Facilities`-Recht.
+
Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action, Scope)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action, scope }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
Rechteprüfung auf Controller-Actions:
@@ -87,6 +89,8 @@ Der Basis-Rollen-Seed (`DbSeeder.SeedBaseRolesAsync`, siehe unten) wurde bei die
**Rollen-Rechte-Matrix und User-Overrides verwalten (Admin-Flow):** Eine neu angelegte Rolle (`RoleService.CreateAsync`) hat zunächst keine `RolePermission`-Einträge — die Rechte-Matrix wird separat gesetzt über `GET /api/roles/{id}` (Rolle inkl. ihrer aktuellen `RolePermission`-Liste, `RoleService.GetByIdWithPermissionsAsync`) und `PUT /api/roles/{id}/permissions` (`RoleService.UpdatePermissionsAsync` — ersetzt die komplette `RolePermission`-Menge der Rolle durch die übergebene Menge, kein inkrementelles Patchen). Individuelle `UserPermissionOverride`-Ausnahmen eines Users werden über `GET/POST/DELETE /api/users/{id}/permission-overrides[...]` verwaltet (`UserService.GetPermissionOverridesAsync`/`AddPermissionOverrideAsync`/`RemovePermissionOverrideAsync` — `AddPermissionOverrideAsync` ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, werden dessen `Effect` **und** `Scope` aktualisiert statt dupliziert). Diese Endpoints liegen auf `RolesController`/`UsersController` (die `.../permission-overrides`-Routen), gegated über `[RequirePermission(ModuleType.UserManagement, View|Edit)]`. Admin-UI dazu: `omsorgapp/src/modules/settings/` (`SettingsPage`, `RolesPanel`, `RolePermissionMatrix`, `UserOverridesPanel`).
+**Rolle löschen (seit 2026-08-10):** `DELETE /api/roles/{id}` (`[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]`) — Löschschutz analog zu `ValueListService.DeleteItemAsync`, aber ohne die generische `IValueListUsageChecker`-Abstraktion, da eine Rolle nur über genau einen Ownership-Anker referenziert wird (`User.RoleId`, echte FK statt Freitext): `RoleService.DeleteAsync` prüft über `IUserRepository.GetUserIdsByRoleAsync` (bereits für die Cache-Invalidierung bei Rechte-Änderungen vorhanden), ob noch Benutzer dieser Rolle zugewiesen sind, und lehnt mit `409` + Anzahl im Klartext ab (`DeleteRoleResult`/`DeleteRoleFailureReason`), statt zu löschen. Hard-Delete (kein Soft-Delete/Papierkorb) — `Role` ist `Entity`, nicht `AuditableEntity`, kein eigenständiges Core-Objekt mit Löschprotokoll-Anforderung. **Wichtig:** `DbSeeder.SeedBaseRolesAsync` legt eine der vier Basis-Rollen (Geschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienst) bei fehlendem Namen bei jedem Start erneut an (idempotent pro Rollenname, siehe dort) — eine gelöschte Basis-Rolle taucht nach einem Neustart automatisch wieder auf, das ist bestehendes, dokumentiertes Verhalten, keine Neuerung dieses Schritts. Frontend: `RolesPanel.jsx` zeigt einen Löschen-Button je Rolle (nur mit `UserManagement`/`Delete`), zeigt die `409`-Fehlermeldung des Servers an statt sie zu verschlucken.
+
**Datenebenen-Scope (`PermissionScope`, "nur eigene Daten"):** Dritte Dimension neben Modul×Aktion — jede `RolePermission`/`UserPermissionOverride`-Zeile trägt zusätzlich `Scope` (`All` oder `Own`, `src/OmsorgCore.Domain/Enums/PermissionScope.cs`). `PermissionService.GetScopeAsync(userId, module, action)` löst das auf und liefert `PermissionScope?` (`null` = gar nicht gewährt) — ein Override ersetzt dabei die komplette Zelle (Grant **und** Scope) der Rolle, es wird nicht gemergt, analog zur bestehenden Effect-Semantik. `HasPermissionAsync`/`RequirePermissionAttribute` bleiben bewusst scope-unabhängig (ein Own-User muss den Endpunkt-Gate trotzdem passieren) — die eigentliche Einschränkung passiert in den Application-Services: `EmployeeService`/`ContractService` konsultieren `GetScopeAsync` vor `GetPagedAsync`/`GetByIdAsync` und filtern bei `Own` auf `ICurrentUserService.EmployeeId` (neuer JWT-Claim `"employeeId"`, aus `User.EmployeeId`, nur eingebettet wenn gesetzt — wirkt daher erst mit dem nächsten Token-Refresh, wenn die Verknüpfung sich ändert). Own ohne verknüpfte `EmployeeId` liefert bewusst keine Datensätze (nicht "alle", nicht 500). **`Employees`, `Contracts`, `Absences` und `TimeEntries` werten den Scope aktuell aus** (alle vier haben einen Ownership-Anker: `User.EmployeeId`, `Contract.EmployeeId`, `Absence.EmployeeId`, `TimeEntry.EmployeeId` — siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" und "Zeiterfassung" unten) — alle anderen Module ignorieren `Scope` faktisch, weil ihnen kein Ownership-Anker zugrunde liegt (`Order` z. B. hat noch keinen Mitarbeiter-Bezug).
**Wichtig:** Wird ein neuer `ModuleType` oder `PermissionAction`-Wert hinzugefügt, oder ändert sich sonst das Rollen-/Rechtesystem, muss dieser Abschnitt (Rechtesystem) im selben Change aktualisiert werden — diese Dokumentation ist keine Momentaufnahme, sondern muss mit der Software mitwachsen.
@@ -160,7 +164,7 @@ Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService`
- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden.
- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
-- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet. `AddContractDetailsAndQueryFilter` ist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch angewendet). `AddPermissionScope` (fügt `Scope` auf `role_permissions`/`user_permission_overrides` hinzu, siehe "Rechtesystem") wurde per `dotnet ef database update` erfolgreich gegen die echte Instanz angewendet. `AddFacilityConditionsAndQualificationRates` (Konditionen-Felder auf `Facility` + Tabelle `facility_qualification_rates`, FR-EIN-4), `AddAbsences` (Tabelle `absences`, FR-CON-1/FR-EM-3) und `AddTimeEntryStatusAndSurchargeHours` (`TimeEntry.StatusId`+Zuschlagsstunden, `ValueListItem.IsEditableByOwner`, `ValueListItemTransition.RequiresApproval`, FR-ZE-1/FR-ZE-2) wurden beim automatischen API-Start erfolgreich gegen die echte Instanz angewendet.
+- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet. `AddContractDetailsAndQueryFilter` ist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch angewendet). `AddPermissionScope` (fügt `Scope` auf `role_permissions`/`user_permission_overrides` hinzu, siehe "Rechtesystem") wurde per `dotnet ef database update` erfolgreich gegen die echte Instanz angewendet. `AddFacilityConditionsAndQualificationRates` (Konditionen-Felder auf `Facility` + Tabelle `facility_qualification_rates`, FR-EIN-4), `AddAbsences` (Tabelle `absences`, FR-CON-1/FR-EM-3) und `AddTimeEntryStatusAndSurchargeHours` (`TimeEntry.StatusId`+Zuschlagsstunden, `ValueListItem.IsEditableByOwner`, `ValueListItemTransition.RequiresApproval`, FR-ZE-1/FR-ZE-2) wurden beim automatischen API-Start erfolgreich gegen die echte Instanz angewendet. `AddTravelCostModeAndEmployeeFacilityDistances` (`Facility.TravelCostMode`/`TravelCostPerKm` + Tabelle `employee_facility_distances`, siehe "Konditionen einer Einrichtung") ist erzeugt (`dotnet build`/`dotnet test` grün), aber noch nicht gegen eine echte Instanz verifiziert.
## Build- und Run-Befehle
@@ -190,7 +194,7 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal.
-- `DbSeeder.SeedBaseRolesAsync` gegen echte PostgreSQL-Instanz verifiziert: legt `Geschäftsführung`/`Disposition/Buchhaltung`/`Recruiting`/`Außendienst` mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname). **Hinweis:** Seit `AddPermissionScope` bekommt `Außendienst` neu `Employees.View`+`Contracts.View` (Scope `Own`) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 2 erwartet), aber noch nicht erneut per echtem Seed-Lauf verifiziert (der Seed läuft nur bei leerer `Roles`-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz).
+- `DbSeeder.SeedBaseRolesAsync` gegen echte PostgreSQL-Instanz verifiziert: legt `Geschäftsführung`/`Disposition/Buchhaltung`/`Recruiting`/`Außendienst` mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname). **Hinweis:** Seit `AddPermissionScope` bekommt `Außendienst` neu `Employees.View`+`Contracts.View` (Scope `Own`), seit den Absences-/TimeEntries-/EmployeeFacilityDistances-Erweiterungen zusätzlich `Absences.{Create,View,Edit}`, `TimeEntries.{Create,View,Edit}` und `EmployeeFacilityDistances.{Create,View,Edit}` (alle Scope `Own`) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 11 erwartet), aber noch nicht erneut per echtem Seed-Lauf verifiziert (der Seed läuft nur bei leerer `Roles`-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz).
## Konfigurierbare Auswahllisten
@@ -231,7 +235,10 @@ Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebt
Elf der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschläge, Fahrtkosten, Mindeststunden, Pausenregelung, Abrechnungsintervall, Zahlungsziel, individuelle Vereinbarungen) sind flache, nullable Felder direkt auf `Facility` — kein `OwnsOne`/keine eigene Tabelle, analog zu Adresse/Rechnungsadresse auf `Facility` selbst und den Finanzfeldern auf `Contract`:
-- `BillingRate` (Verrechnungssatz, EUR/Std.), `NightSurchargePercent`/`SaturdaySurchargePercent`/`SundaySurchargePercent`/`HolidaySurchargePercent` (Zuschläge als **Prozent** auf den Verrechnungssatz, nicht als EUR-Betrag), `TravelCostRate` (Fahrtkosten als **Pauschale je Einsatz**, kein km-Modell), `MinimumHours` (Mindeststunden je Einsatz), `BreakPolicy` (Pausenregelung, Freitext), `PaymentTermDays` (Zahlungsziel in Tagen), `IndividualAgreements` (Freitext).
+- `BillingRate` (Verrechnungssatz, EUR/Std.), `NightSurchargePercent`/`SaturdaySurchargePercent`/`SundaySurchargePercent`/`HolidaySurchargePercent` (Zuschläge als **Prozent** auf den Verrechnungssatz, nicht als EUR-Betrag), `MinimumHours` (Mindeststunden je Einsatz), `BreakPolicy` (Pausenregelung, Freitext), `PaymentTermDays` (Zahlungsziel in Tagen), `IndividualAgreements` (Freitext).
+- **Fahrtkosten (seit 2026-08-10 zwei Modi statt nur Pauschale):** `TravelCostMode` (`"Pauschale"` oder `"ProKilometer"`, Default `"Pauschale"`, im Controller gegen ein festes Literal-Array geprüft — **bewusst keine `ValueList`**, da die Rechnungserstellung später hart zwischen genau diesen zwei Fällen unterscheiden muss, ein dritter admin-hinzufügbarer Wert würde die künftige `FR-RE-1`-Berechnungslogik lautlos brechen, analog zur Begründung bei `AbsenceStatus.IsInitial`) entscheidet, welches Feld gilt: `TravelCostRate` (Pauschale je Einsatz, EUR) oder `TravelCostPerKm` (EUR/km) × `EmployeeFacilityDistance.DistanceKm`. Eine feste Facility-Distanz reicht nicht, weil jeder Mitarbeiter von einem anderen Wohnort anfährt — daher `EmployeeFacilityDistance` (`Facility`×`Employee` → `DistanceKm`, `AuditableEntity`) als **zweite** 1:n-Unterressource von Facility, exakt nach dem `FacilityQualificationRate`-Muster: `GET/POST/PUT/DELETE /api/facilities/{facilityId}/employee-distances[/...]` (`EmployeeFacilityDistancesController`), gegated über dieselben `Facilities`-Rechte, kein eigener `ModuleType`. `Create` validiert zusätzlich, dass `EmployeeId` auf einen existierenden Mitarbeiter zeigt (`IEmployeeService.GetByIdAsync`) und dass für das Facility/Employee-Paar noch keine (nicht gelöschte) Distanz existiert. Soft-Delete, über `TrashController` (`api/trash/employee-facility-distances/...`) wiederherstellbar.
+- **Selbstbedienung durch den Außendienst (seit 2026-08-10, `MyFacilityDistancesController`, Route `api/me/facility-distances`):** Mitarbeiter sollen ihre eigene Fahrtstrecke selbst über `omsorgWeb/mitarbeiter-app` pflegen können, statt dass das Büro jede Kilometerangabe manuell einträgt. Dafür bewusst **kein** `Facilities`-Recht (das würde Konditionen/CRM-Daten offenlegen, die dem Außendienst laut Rechtematrix nicht zustehen), sondern ein neuer, eigenständiger `ModuleType.EmployeeFacilityDistances` (siehe "Rechtesystem" oben) — `GET /api/me/facility-distances` (eigene Distanzen inkl. `FacilityName`), `GET /api/me/facility-distances/facilities` (minimale Einrichtungsauswahl, nur `Id`/`Name` über `FacilityOptionResponse`, für das Formular-Dropdown), `POST`/`PUT` (Anlegen/Bearbeiten). `EmployeeId` kommt bei jeder Aktion ausschließlich aus `ICurrentUserService.EmployeeId` (JWT-Claim), nie vom Client — bei fehlender Verknüpfung `400` statt eines FK-Fehlers, analog zum `AbsenceService.CreateAsync`-Fallback. Bewusst **kein** `DELETE` hier (Löschen bleibt Büro-Aufgabe über den Papierkorb). Nutzt intern denselben `IEmployeeFacilityDistanceService`/dieselbe Tabelle wie `EmployeeFacilityDistancesController` — zwei Controller auf demselben Application-Service, unterschiedliche Zugriffsrechte, kein Datenmodell-Unterschied.
+- **Zugleich behobener Bestandsfehler:** `FacilityService.UpdateAsync` kopierte die elf Konditionsfelder bislang gar nicht auf die getrackte Entität — der Controller validierte sie korrekt, aber `PUT /api/facilities/{id}` verwarf sie stillschweigend (kein Fehler, kein Log, das Feld blieb einfach `null`/unverändert). Betraf `BillingRate`/alle vier Zuschläge/`TravelCostRate`/`MinimumHours`/`BreakPolicy`/`BillingInterval`/`PaymentTermDays`/`IndividualAgreements` seit deren Einführung. Jetzt behoben, Regressionstest: `FacilityServiceTests.UpdateAsync_PersistsKonditionenFields`.
- `BillingInterval` wird wie `FacilityType`/`ContractType` gegen die admin-editierbare `ValueList` `"BillingInterval"` (Wöchentlich/Monatlich/Quartalsweise) validiert — siehe "Konfigurierbare Auswahllisten".
- Alle elf Felder sind nur über `PUT /api/facilities/{id}` (`UpdateFacilityRequest`) setzbar, nicht beim Anlegen (`CreateFacilityRequest`) — analog zu `CrmStatus`, der ebenfalls erst nach dem Anlegen über "Bearbeiten" gepflegt wird.
@@ -273,7 +280,7 @@ Drei Endpoints statt zwei (Absence hat Edit+Decide, TimeEntry hat Edit+Submit+De
## Offene Punkte
-- `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT/DELETE /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]` (`FacilityContactsController`) und `FacilityQualificationRate` (FR-EIN-4, qualifikationsabhängige Preise) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`, siehe "Konditionen einer Einrichtung" oben) — beide bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da beides kein eigenständiges Core-Objekt ist. Löschen (`Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`) ist jetzt durchgängig Soft-Delete (`IsDeleted`/`DeletedAt`, `PermissionAction.Delete` je Modul, `DELETE`-Endpoint pro Controller) und über den `TrashController` (`api/trash/...`, `PermissionAction.Recover` je Modul, "Papierkorb"-Seite in `omsorgapp`) wiederherstellbar.
+- `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT/DELETE /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner), `FacilityQualificationRate` (FR-EIN-4, qualifikationsabhängige Preise) und `EmployeeFacilityDistance` (FR-EIN-4, kilometerbasierte Fahrtkosten) als 1:n-Unterressourcen unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]`, `.../qualification-rates[/...]` bzw. `.../employee-distances[/...]` (`FacilityContactsController`/`FacilityQualificationRatesController`/`EmployeeFacilityDistancesController`, siehe "Konditionen einer Einrichtung" oben) — alle drei bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da keines ein eigenständiges Core-Objekt ist. Löschen (`Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`EmployeeFacilityDistance`) ist jetzt durchgängig Soft-Delete (`IsDeleted`/`DeletedAt`, `PermissionAction.Delete` je Modul, `DELETE`-Endpoint pro Controller) und über den `TrashController` (`api/trash/...`, `PermissionAction.Recover` je Modul, "Papierkorb"-Seite in `omsorgapp`) wiederherstellbar.
- `Contract` hat jetzt ebenfalls volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT /api/contracts`, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`) nach demselben Facility-Muster, inkl. `ContractCreatedEvent`. Deckt FR-MA-2 auf Backend-Seite ab: `WeeklyHours` (Arbeitszeit), `HourlyWage` (Stundenlohn), `AllowancesDescription` (Zuschläge, Freitext), `OvertimeRules` (Überstundenregelung, Freitext), `VacationDaysPerYear` (Urlaubsanspruch), `ProbationPeriodMonths` (Probezeit) — alle nullable, da ein Vertrag entweder einem Mitarbeiter oder einer Einrichtung zugeordnet ist (`EmployeeId`/`FacilityId`, mindestens eins muss gesetzt sein, per Controller-Validierung erzwungen) und nicht jeder Vertragstyp alle Felder braucht. `ContractConfiguration` hat jetzt (wie `Facility`) einen `HasQueryFilter(!IsDeleted)`. FR-MA-2 ist damit inkl. `omsorgapp`-UI abgeschlossen: "Verträge"-Tab in `EmployeeDetailPanel` (`ContractsList`/`ContractForm`/`Create-`/`EditContractDialog.jsx`), neue Verträge starten als "Entwurf", Statuswechsel nur im Bearbeiten-Formular, Löschen als Soft-Delete über den Papierkorb wiederherstellbar (siehe `omsorgapp/CLAUDE.md`).
- `Order` hat jetzt ebenfalls volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT /api/orders`, gegated über `[RequirePermission(ModuleType.Orders, ...)]`) nach demselben Facility/Contract-Muster, inkl. `OrderCreatedEvent`. Deckt FR-EM-1 auf Backend-Seite ab: `FacilityContactId` (optionaler Ansprechpartner, gegen `FacilityId` cross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst `400`), `ShiftType` (Schichtart, Freitext), `RequiredHeadcount` (Anzahl Mitarbeiter, mindestens 1), `Conditions` (Konditionen, Freitext), `Priority` (Priorität, Freitext). Der Auftragsstatus (FR-EM-2, `Order.StatusId`) ist Teil der generischen Auswahllisten — siehe "Konfigurierbare Auswahllisten" unten. `OrderConfiguration` hat jetzt (wie `Facility`/`Contract`) einen `HasQueryFilter(!IsDeleted)`. Kein `omsorgapp`-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung"). `TimeEntry` hat jetzt ebenfalls volles Repository/Service/Controller (FR-ZE-1/FR-ZE-2, siehe "Zeiterfassung" oben) — `Invoice` hat weiterhin nur Domain-Entität + DB-Konfiguration, nächster Schritt folgt demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api) und muss FR-ZE-3 (nur freigegebene Zeit fließt ein) auf Domain-/Application-Ebene erzwingen.
- Dokumentenarchiv (FR-MA-3) hat jetzt volles Repository/Service/Controller (`DocumentsController`, `GET/POST /api/documents`, `PUT /{id}` für Metadaten, `GET /{id}/download`, `DELETE /{id}`) — siehe "Dokumentenarchiv" oben. `omsorgapp`-UI ("Dokumente"-Tab in der Personalakte) existiert jetzt ebenfalls, siehe `omsorgapp/CLAUDE.md`. Kein Außendienst-Selbstzugriff, kein physisches Löschen von Dateien beim Soft-Delete/Papierkorb (keine Hard-Purge-Stelle im System, die man konsistent mitziehen müsste).
@@ -285,7 +292,7 @@ Drei Endpoints statt zwei (Absence hat Edit+Decide, TimeEntry hat Edit+Submit+De
Aus der Swagger/OpenAPI-JSON dieses Backends (`/swagger/v1/swagger.json`, nur im Development-Modus aktiv) werden mit `openapi-generator-cli` typisierte Clients generiert — `omsorgapp/api-client-ts/` (TypeScript, `typescript-fetch`-Template) und `omsorgWeb/mitarbeiter-app/api-client-php/` (PHP).
-**Wichtig — beide generierten Clients sind im echten Datenpfad, nicht optional:** Alle Wrapper unter `omsorgapp/src/api/*Api.js` (`employeesApi.js`, `facilitiesApi.js`, `facilityContactsApi.js`, `facilityQualificationRatesApi.js`, `usersApi.js`, `rolesApi.js`, `valueListsApi.js`, `auditLogApi.js`, `authApi.js`, `absencesApi.js`, `timeEntriesApi.js`, ...) importieren die jeweilige `*Api`-Klasse aus dem generierten Paket `omsorgcore-client-ts` (`import { ... } from "omsorgcore-client-ts"`) und reichen Requests/Responses **ungeprüft typisiert** durch. `omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php` nutzt seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls den generierten PHP-Client (`../api-client-php/`, `\OmsorgCoreClient\Api\...`) statt rohem cURL — beide Frontends müssen also nach einer Contract-Änderung neu generiert werden, nicht nur `omsorgapp`.
+**Wichtig — beide generierten Clients sind im echten Datenpfad, nicht optional:** Alle Wrapper unter `omsorgapp/src/api/*Api.js` (`employeesApi.js`, `facilitiesApi.js`, `facilityContactsApi.js`, `facilityQualificationRatesApi.js`, `employeeFacilityDistancesApi.js`, `usersApi.js`, `rolesApi.js`, `valueListsApi.js`, `auditLogApi.js`, `authApi.js`, `absencesApi.js`, `timeEntriesApi.js`, ...) importieren die jeweilige `*Api`-Klasse aus dem generierten Paket `omsorgcore-client-ts` (`import { ... } from "omsorgcore-client-ts"`) und reichen Requests/Responses **ungeprüft typisiert** durch. `omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php` nutzt seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls den generierten PHP-Client (`../api-client-php/`, `\OmsorgCoreClient\Api\...`) statt rohem cURL — beide Frontends müssen also nach einer Contract-Änderung neu generiert werden, nicht nur `omsorgapp`.
**Verbindliche Regel: nach *jeder* Änderung an einem Controller oder DTO in `omsorgCore.Api/Contracts` müssen `omsorgapp/api-client-ts` **und** `omsorgWeb/mitarbeiter-app/api-client-php` neu generiert und neu gebaut werden — noch in demselben Change, nicht als Nachgang.** Wird das vergessen, gibt es **keinen Fehler, keine Exception, keine Warnung** — der generierte Client kennt das neue/geänderte Feld schlicht nicht und lässt es beim Serialisieren/Deserialisieren stillschweigend weg. Das Symptom in der UI: Speichern/Anlegen meldet Erfolg, aber das betroffene Feld kommt nie im Backend an bzw. taucht nie in der Antwort auf — schwer zu debuggen, weil weder Backend noch Frontend-Code einen sichtbaren Fehler werfen (siehe FR-EIN-1/Website-Vorfall, 2026-08-08).
@@ -310,7 +317,7 @@ Verbindliche Regeln für das Datenmodell:
2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) läuft automatisch über den `AuditSaveChangesInterceptor` (siehe "Audit-Log" oben), keine Handarbeit pro Entität nötig.
-5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`Absence`/`TimeEntry` (`IsDeleted`/`DeletedAt`, `DELETE`-Endpoints gegated über `PermissionAction.Delete`, Wiederherstellung über `TrashController`/`PermissionAction.Recover`); `Invoice` hat noch kein CRUD, daher hier noch nicht relevant.
+5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`EmployeeFacilityDistance`/`Absence`/`TimeEntry` (`IsDeleted`/`DeletedAt`, `DELETE`-Endpoints gegated über `PermissionAction.Delete`, Wiederherstellung über `TrashController`/`PermissionAction.Recover`); `Invoice` hat noch kein CRUD, daher hier noch nicht relevant.
6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7).
7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeFacilityDistanceRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeFacilityDistanceRequest.cs
new file mode 100644
index 0000000..bd08384
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeFacilityDistanceRequest.cs
@@ -0,0 +1,5 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record CreateEmployeeFacilityDistanceRequest(
+ Guid EmployeeId,
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateMyFacilityDistanceRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateMyFacilityDistanceRequest.cs
new file mode 100644
index 0000000..40f759d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateMyFacilityDistanceRequest.cs
@@ -0,0 +1,5 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record CreateMyFacilityDistanceRequest(
+ Guid FacilityId,
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeFacilityDistanceResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeFacilityDistanceResponse.cs
new file mode 100644
index 0000000..3dc340f
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeFacilityDistanceResponse.cs
@@ -0,0 +1,9 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record EmployeeFacilityDistanceResponse(
+ Guid Id,
+ Guid FacilityId,
+ Guid EmployeeId,
+ string EmployeeFirstName,
+ string EmployeeLastName,
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityOptionResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityOptionResponse.cs
new file mode 100644
index 0000000..95e0100
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityOptionResponse.cs
@@ -0,0 +1,9 @@
+namespace OmsorgCore.Api.Contracts;
+
+///
+/// Minimale Einrichtungsauswahl für Selbstbedienungs-Formulare ohne Facilities-Recht (z. B.
+/// MyFacilityDistancesController) — bewusst nur Id/Name, keine Konditionen/CRM-Daten.
+///
+public record FacilityOptionResponse(
+ Guid Id,
+ string Name);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityResponse.cs
index 070c150..e06e2cc 100644
--- a/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityResponse.cs
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/FacilityResponse.cs
@@ -21,6 +21,8 @@ public record FacilityResponse(
decimal? SundaySurchargePercent,
decimal? HolidaySurchargePercent,
decimal? TravelCostRate,
+ string TravelCostMode,
+ decimal? TravelCostPerKm,
decimal? MinimumHours,
string? BreakPolicy,
string? BillingInterval,
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/MyFacilityDistanceResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/MyFacilityDistanceResponse.cs
new file mode 100644
index 0000000..e928764
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/MyFacilityDistanceResponse.cs
@@ -0,0 +1,7 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record MyFacilityDistanceResponse(
+ Guid Id,
+ Guid FacilityId,
+ string FacilityName,
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/TrashEmployeeFacilityDistanceResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/TrashEmployeeFacilityDistanceResponse.cs
new file mode 100644
index 0000000..a7f9800
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/TrashEmployeeFacilityDistanceResponse.cs
@@ -0,0 +1,8 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record TrashEmployeeFacilityDistanceResponse(
+ Guid Id,
+ Guid FacilityId,
+ Guid EmployeeId,
+ decimal DistanceKm,
+ DateTime? DeletedAt);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeFacilityDistanceRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeFacilityDistanceRequest.cs
new file mode 100644
index 0000000..40061bf
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeFacilityDistanceRequest.cs
@@ -0,0 +1,4 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record UpdateEmployeeFacilityDistanceRequest(
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateFacilityRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateFacilityRequest.cs
index dfec2a8..3918e9e 100644
--- a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateFacilityRequest.cs
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateFacilityRequest.cs
@@ -20,6 +20,8 @@ public record UpdateFacilityRequest(
decimal? SundaySurchargePercent,
decimal? HolidaySurchargePercent,
decimal? TravelCostRate,
+ string TravelCostMode,
+ decimal? TravelCostPerKm,
decimal? MinimumHours,
string? BreakPolicy,
string? BillingInterval,
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateMyFacilityDistanceRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateMyFacilityDistanceRequest.cs
new file mode 100644
index 0000000..6361095
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateMyFacilityDistanceRequest.cs
@@ -0,0 +1,4 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record UpdateMyFacilityDistanceRequest(
+ decimal DistanceKm);
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeeFacilityDistancesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeeFacilityDistancesController.cs
new file mode 100644
index 0000000..659b1ba
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeeFacilityDistancesController.cs
@@ -0,0 +1,137 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Entities;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Api.Controllers;
+
+///
+/// Mitarbeiter-Einrichtung-Entfernungen (km) sind eine 1:n-Unterressource von Facility (FR-EIN-4,
+/// Grundlage für Facility.TravelCostMode == "ProKilometer") — kein eigenständiges Core-Objekt, daher
+/// unter /api/facilities/{facilityId}/employee-distances und mit den gleichen Facilities-Rechten
+/// gegated statt einem eigenen ModuleType, analog zu FacilityQualificationRate.
+///
+[ApiController]
+[Authorize]
+[Route("api/facilities/{facilityId:guid}/employee-distances")]
+public class EmployeeFacilityDistancesController : ControllerBase
+{
+ private readonly IFacilityService _facilityService;
+ private readonly IEmployeeService _employeeService;
+ private readonly IEmployeeFacilityDistanceService _employeeFacilityDistanceService;
+
+ public EmployeeFacilityDistancesController(
+ IFacilityService facilityService,
+ IEmployeeService employeeService,
+ IEmployeeFacilityDistanceService employeeFacilityDistanceService)
+ {
+ _facilityService = facilityService;
+ _employeeService = employeeService;
+ _employeeFacilityDistanceService = employeeFacilityDistanceService;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.View)]
+ public async Task>> GetAll(Guid facilityId, CancellationToken cancellationToken)
+ {
+ if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
+ {
+ return NotFound();
+ }
+
+ var distances = await _employeeFacilityDistanceService.GetByFacilityIdAsync(facilityId, cancellationToken);
+ return Ok(distances.Select(d => ToResponse(d, d.Employee)).ToList());
+ }
+
+ [HttpPost]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
+ public async Task> Create(Guid facilityId, CreateEmployeeFacilityDistanceRequest request, CancellationToken cancellationToken)
+ {
+ if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
+ {
+ return NotFound();
+ }
+
+ var employee = await _employeeService.GetByIdAsync(request.EmployeeId, cancellationToken);
+ if (employee is null)
+ {
+ return BadRequest("EmployeeId verweist auf keinen existierenden Mitarbeiter.");
+ }
+
+ if (request.DistanceKm < 0)
+ {
+ return BadRequest("DistanceKm darf nicht negativ sein.");
+ }
+
+ if (await _employeeFacilityDistanceService.GetByFacilityAndEmployeeAsync(facilityId, request.EmployeeId, cancellationToken) is not null)
+ {
+ return BadRequest("Für diesen Mitarbeiter existiert bereits eine Entfernung zu dieser Einrichtung.");
+ }
+
+ var distance = new EmployeeFacilityDistance
+ {
+ FacilityId = facilityId,
+ EmployeeId = request.EmployeeId,
+ DistanceKm = request.DistanceKm
+ };
+
+ var created = await _employeeFacilityDistanceService.CreateAsync(distance, cancellationToken);
+ return CreatedAtAction(nameof(GetAll), new { facilityId }, ToResponse(created, employee));
+ }
+
+ [HttpPut("{id:guid}")]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
+ public async Task> Update(Guid facilityId, Guid id, UpdateEmployeeFacilityDistanceRequest request, CancellationToken cancellationToken)
+ {
+ var existing = await _employeeFacilityDistanceService.GetByIdAsync(id, cancellationToken);
+ if (existing is null || existing.FacilityId != facilityId)
+ {
+ return NotFound();
+ }
+
+ if (request.DistanceKm < 0)
+ {
+ return BadRequest("DistanceKm darf nicht negativ sein.");
+ }
+
+ var updates = new EmployeeFacilityDistance
+ {
+ DistanceKm = request.DistanceKm
+ };
+
+ var updated = await _employeeFacilityDistanceService.UpdateAsync(id, updates, cancellationToken);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ var employee = await _employeeService.GetByIdAsync(updated.EmployeeId, cancellationToken);
+ return Ok(ToResponse(updated, employee!));
+ }
+
+ [HttpDelete("{id:guid}")]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
+ public async Task Delete(Guid facilityId, Guid id, CancellationToken cancellationToken)
+ {
+ var existing = await _employeeFacilityDistanceService.GetByIdAsync(id, cancellationToken);
+ if (existing is null || existing.FacilityId != facilityId)
+ {
+ return NotFound();
+ }
+
+ var deleted = await _employeeFacilityDistanceService.DeleteAsync(id, cancellationToken);
+ return deleted ? NoContent() : NotFound();
+ }
+
+ private static EmployeeFacilityDistanceResponse ToResponse(EmployeeFacilityDistance distance, Employee employee)
+ => new(
+ distance.Id,
+ distance.FacilityId,
+ distance.EmployeeId,
+ employee.FirstName,
+ employee.LastName,
+ distance.DistanceKm);
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/FacilitiesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/FacilitiesController.cs
index 85c264c..e6aeb22 100644
--- a/omsorgCore/src/OmsorgCore.Api/Controllers/FacilitiesController.cs
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/FacilitiesController.cs
@@ -19,6 +19,7 @@ public class FacilitiesController : ControllerBase
private const string FacilityTypeListKey = "FacilityType";
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
private const string BillingIntervalListKey = "BillingInterval";
+ private static readonly string[] TravelCostModes = { "Pauschale", "ProKilometer" };
private readonly IFacilityService _facilityService;
private readonly IValueListRepository _valueListRepository;
@@ -222,6 +223,7 @@ public class FacilitiesController : ControllerBase
if (request.BillingRate is < 0
|| request.TravelCostRate is < 0
+ || request.TravelCostPerKm is < 0
|| request.MinimumHours is < 0
|| request.NightSurchargePercent is < 0
|| request.SaturdaySurchargePercent is < 0
@@ -232,6 +234,11 @@ public class FacilitiesController : ControllerBase
return BadRequest("Konditionswerte dürfen nicht negativ sein.");
}
+ if (!TravelCostModes.Contains(request.TravelCostMode))
+ {
+ return BadRequest($"TravelCostMode muss einer der folgenden Werte sein: {string.Join(", ", TravelCostModes)}.");
+ }
+
var updates = new Facility
{
Name = request.Name,
@@ -243,6 +250,8 @@ public class FacilitiesController : ControllerBase
SundaySurchargePercent = request.SundaySurchargePercent,
HolidaySurchargePercent = request.HolidaySurchargePercent,
TravelCostRate = request.TravelCostRate,
+ TravelCostMode = request.TravelCostMode,
+ TravelCostPerKm = request.TravelCostPerKm,
MinimumHours = request.MinimumHours,
BreakPolicy = request.BreakPolicy,
BillingInterval = request.BillingInterval,
@@ -319,6 +328,8 @@ public class FacilitiesController : ControllerBase
facility.SundaySurchargePercent,
facility.HolidaySurchargePercent,
facility.TravelCostRate,
+ facility.TravelCostMode,
+ facility.TravelCostPerKm,
facility.MinimumHours,
facility.BreakPolicy,
facility.BillingInterval,
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/MyFacilityDistancesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/MyFacilityDistancesController.cs
new file mode 100644
index 0000000..0790d32
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/MyFacilityDistancesController.cs
@@ -0,0 +1,143 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Entities;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Api.Controllers;
+
+///
+/// Selbstbedienungs-Endpoint für Außendienst-Mitarbeiter (OMSORG Connect,
+/// `omsorgWeb/mitarbeiter-app`): eigene Fahrtstrecke (km) je Einrichtung pflegen, Grundlage für die
+/// kilometerbasierte Fahrtkostenabrechnung (Facility.TravelCostMode == "ProKilometer", FR-EIN-4).
+/// Bewusst ein eigener ModuleType (EmployeeFacilityDistances) statt Facilities-Rechten — der
+/// Außendienst darf hier ausschließlich seine eigenen Datensätze sehen/anlegen/bearbeiten, nicht die
+/// Konditionen/CRM-Daten einer Einrichtung. EmployeeId kommt IMMER serverseitig aus dem JWT-Claim,
+/// nie vom Client (analog AbsencesController/TimeEntriesController).
+///
+[ApiController]
+[Authorize]
+[Route("api/me/facility-distances")]
+public class MyFacilityDistancesController : ControllerBase
+{
+ private readonly IEmployeeFacilityDistanceService _employeeFacilityDistanceService;
+ private readonly IFacilityService _facilityService;
+ private readonly ICurrentUserService _currentUserService;
+
+ public MyFacilityDistancesController(
+ IEmployeeFacilityDistanceService employeeFacilityDistanceService,
+ IFacilityService facilityService,
+ ICurrentUserService currentUserService)
+ {
+ _employeeFacilityDistanceService = employeeFacilityDistanceService;
+ _facilityService = facilityService;
+ _currentUserService = currentUserService;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.EmployeeFacilityDistances, PermissionAction.View)]
+ public async Task>> GetAll(CancellationToken cancellationToken)
+ {
+ var employeeId = RequireOwnEmployeeId();
+ if (employeeId is null)
+ {
+ return BadRequest("Dieser Benutzer ist mit keinem Mitarbeiter verknüpft.");
+ }
+
+ var facilities = (await _facilityService.GetAllAsync(cancellationToken)).ToDictionary(f => f.Id);
+ var all = new List();
+
+ foreach (var facility in facilities.Values)
+ {
+ var distance = await _employeeFacilityDistanceService.GetByFacilityAndEmployeeAsync(facility.Id, employeeId.Value, cancellationToken);
+ if (distance is not null)
+ {
+ all.Add(new MyFacilityDistanceResponse(distance.Id, facility.Id, facility.Name, distance.DistanceKm));
+ }
+ }
+
+ return Ok(all);
+ }
+
+ [HttpGet("facilities")]
+ [RequirePermission(ModuleType.EmployeeFacilityDistances, PermissionAction.View)]
+ public async Task>> GetFacilityOptions(CancellationToken cancellationToken)
+ {
+ var facilities = await _facilityService.GetAllAsync(cancellationToken);
+ return Ok(facilities.OrderBy(f => f.Name).Select(f => new FacilityOptionResponse(f.Id, f.Name)).ToList());
+ }
+
+ [HttpPost]
+ [RequirePermission(ModuleType.EmployeeFacilityDistances, PermissionAction.Create)]
+ public async Task> Create(CreateMyFacilityDistanceRequest request, CancellationToken cancellationToken)
+ {
+ var employeeId = RequireOwnEmployeeId();
+ if (employeeId is null)
+ {
+ return BadRequest("Dieser Benutzer ist mit keinem Mitarbeiter verknüpft.");
+ }
+
+ var facility = await _facilityService.GetByIdAsync(request.FacilityId, cancellationToken);
+ if (facility is null)
+ {
+ return BadRequest("FacilityId verweist auf keine existierende Einrichtung.");
+ }
+
+ if (request.DistanceKm < 0)
+ {
+ return BadRequest("DistanceKm darf nicht negativ sein.");
+ }
+
+ if (await _employeeFacilityDistanceService.GetByFacilityAndEmployeeAsync(request.FacilityId, employeeId.Value, cancellationToken) is not null)
+ {
+ return BadRequest("Für diese Einrichtung existiert bereits eine eigene Entfernung.");
+ }
+
+ var distance = new EmployeeFacilityDistance
+ {
+ FacilityId = request.FacilityId,
+ EmployeeId = employeeId.Value,
+ DistanceKm = request.DistanceKm
+ };
+
+ var created = await _employeeFacilityDistanceService.CreateAsync(distance, cancellationToken);
+ return CreatedAtAction(nameof(GetAll), new MyFacilityDistanceResponse(created.Id, facility.Id, facility.Name, created.DistanceKm));
+ }
+
+ [HttpPut("{id:guid}")]
+ [RequirePermission(ModuleType.EmployeeFacilityDistances, PermissionAction.Edit)]
+ public async Task> Update(Guid id, UpdateMyFacilityDistanceRequest request, CancellationToken cancellationToken)
+ {
+ var employeeId = RequireOwnEmployeeId();
+ if (employeeId is null)
+ {
+ return BadRequest("Dieser Benutzer ist mit keinem Mitarbeiter verknüpft.");
+ }
+
+ var existing = await _employeeFacilityDistanceService.GetByIdAsync(id, cancellationToken);
+ if (existing is null || existing.EmployeeId != employeeId.Value)
+ {
+ return NotFound();
+ }
+
+ if (request.DistanceKm < 0)
+ {
+ return BadRequest("DistanceKm darf nicht negativ sein.");
+ }
+
+ var updated = await _employeeFacilityDistanceService.UpdateAsync(id, new EmployeeFacilityDistance { DistanceKm = request.DistanceKm }, cancellationToken);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ var facility = await _facilityService.GetByIdAsync(updated.FacilityId, cancellationToken);
+ return Ok(new MyFacilityDistanceResponse(updated.Id, updated.FacilityId, facility?.Name ?? "", updated.DistanceKm));
+ }
+
+ private Guid? RequireOwnEmployeeId()
+ => _currentUserService.EmployeeId is { } id && id != Guid.Empty ? id : null;
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs
index f7f38e3..fbacfaf 100644
--- a/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs
@@ -84,4 +84,22 @@ public class RolesController : ControllerBase
return NoContent();
}
+
+ [HttpDelete("{id:guid}")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
+ public async Task Delete(Guid id, CancellationToken cancellationToken)
+ {
+ var result = await _roleService.DeleteAsync(id, cancellationToken);
+ if (!result.Success)
+ {
+ return result.FailureReason switch
+ {
+ DeleteRoleFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
+ DeleteRoleFailureReason.InUse => Conflict($"Diese Rolle ist noch {result.AssignedUserCount} Benutzer(n) zugewiesen und kann nicht gelöscht werden."),
+ _ => BadRequest()
+ };
+ }
+
+ return NoContent();
+ }
}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/TrashController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/TrashController.cs
index 4386ce8..37899fd 100644
--- a/omsorgCore/src/OmsorgCore.Api/Controllers/TrashController.cs
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/TrashController.cs
@@ -26,6 +26,7 @@ public class TrashController : ControllerBase
private readonly IOrderService _orderService;
private readonly IFacilityContactService _facilityContactService;
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
+ private readonly IEmployeeFacilityDistanceService _employeeFacilityDistanceService;
private readonly IAbsenceService _absenceService;
private readonly ITimeEntryService _timeEntryService;
@@ -36,6 +37,7 @@ public class TrashController : ControllerBase
IOrderService orderService,
IFacilityContactService facilityContactService,
IFacilityQualificationRateService facilityQualificationRateService,
+ IEmployeeFacilityDistanceService employeeFacilityDistanceService,
IAbsenceService absenceService,
ITimeEntryService timeEntryService)
{
@@ -45,6 +47,7 @@ public class TrashController : ControllerBase
_orderService = orderService;
_facilityContactService = facilityContactService;
_facilityQualificationRateService = facilityQualificationRateService;
+ _employeeFacilityDistanceService = employeeFacilityDistanceService;
_absenceService = absenceService;
_timeEntryService = timeEntryService;
}
@@ -145,6 +148,22 @@ public class TrashController : ControllerBase
return restored ? NoContent() : NotFound();
}
+ [HttpGet("employee-facility-distances")]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
+ public async Task>> GetDeletedEmployeeFacilityDistances([FromQuery] string? search, CancellationToken cancellationToken)
+ {
+ var distances = await _employeeFacilityDistanceService.GetDeletedAsync(search, cancellationToken);
+ return Ok(distances.Select(d => new TrashEmployeeFacilityDistanceResponse(d.Id, d.FacilityId, d.EmployeeId, d.DistanceKm, d.DeletedAt)).ToList());
+ }
+
+ [HttpPost("employee-facility-distances/{id:guid}/restore")]
+ [RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
+ public async Task RestoreEmployeeFacilityDistance(Guid id, CancellationToken cancellationToken)
+ {
+ var restored = await _employeeFacilityDistanceService.RestoreAsync(id, cancellationToken);
+ return restored ? NoContent() : NotFound();
+ }
+
[HttpGet("absences")]
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
public async Task>> GetDeletedAbsences([FromQuery] string? search, CancellationToken cancellationToken)
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeFacilityDistanceRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeFacilityDistanceRepository.cs
new file mode 100644
index 0000000..ec90ee3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeFacilityDistanceRepository.cs
@@ -0,0 +1,16 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IEmployeeFacilityDistanceRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
+ Task GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default);
+ Task AddAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
+ Task UpdateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
+ Task SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
+ Task RestoreAsync(Guid id, CancellationToken cancellationToken = default);
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs
index dc76caa..8b4e290 100644
--- a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs
@@ -18,5 +18,6 @@ public interface IRoleRepository
Task AddPermissionRangeAsync(IEnumerable permissions, CancellationToken cancellationToken = default);
Task ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task AddAsync(Role role, CancellationToken cancellationToken = default);
+ Task RemoveAsync(Role role, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
diff --git a/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs
index 5b8904c..92857cc 100644
--- a/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs
+++ b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs
@@ -14,6 +14,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/DeleteRoleResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/DeleteRoleResult.cs
new file mode 100644
index 0000000..5963d51
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/DeleteRoleResult.cs
@@ -0,0 +1,28 @@
+namespace OmsorgCore.Application.Services;
+
+public enum DeleteRoleFailureReason
+{
+ RoleNotFound,
+ InUse
+}
+
+public class DeleteRoleResult
+{
+ public bool Success { get; init; }
+ public DeleteRoleFailureReason? FailureReason { get; init; }
+
+ /// Nur gesetzt bei - Anzahl der Benutzer, die dieser Rolle noch zugewiesen sind.
+ public int AssignedUserCount { get; init; }
+
+ public static DeleteRoleResult Fail(DeleteRoleFailureReason reason, int assignedUserCount = 0) => new()
+ {
+ Success = false,
+ FailureReason = reason,
+ AssignedUserCount = assignedUserCount
+ };
+
+ public static DeleteRoleResult Ok() => new()
+ {
+ Success = true
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/EmployeeFacilityDistanceService.cs b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeFacilityDistanceService.cs
new file mode 100644
index 0000000..5d56693
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeFacilityDistanceService.cs
@@ -0,0 +1,71 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class EmployeeFacilityDistanceService : IEmployeeFacilityDistanceService
+{
+ private readonly IEmployeeFacilityDistanceRepository _employeeFacilityDistanceRepository;
+
+ public EmployeeFacilityDistanceService(IEmployeeFacilityDistanceRepository employeeFacilityDistanceRepository)
+ {
+ _employeeFacilityDistanceRepository = employeeFacilityDistanceRepository;
+ }
+
+ public Task> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
+ => _employeeFacilityDistanceRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
+
+ public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ => _employeeFacilityDistanceRepository.GetByIdAsync(id, cancellationToken);
+
+ public Task GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default)
+ => _employeeFacilityDistanceRepository.GetByFacilityAndEmployeeAsync(facilityId, employeeId, cancellationToken);
+
+ public async Task CreateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
+ {
+ await _employeeFacilityDistanceRepository.AddAsync(distance, cancellationToken);
+ await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
+ return distance;
+ }
+
+ public async Task UpdateAsync(Guid id, EmployeeFacilityDistance updates, CancellationToken cancellationToken = default)
+ {
+ var distance = await _employeeFacilityDistanceRepository.GetByIdAsync(id, cancellationToken);
+ if (distance is null)
+ {
+ return null;
+ }
+
+ distance.DistanceKm = updates.DistanceKm;
+ distance.UpdatedAt = DateTime.UtcNow;
+
+ await _employeeFacilityDistanceRepository.UpdateAsync(distance, cancellationToken);
+ await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
+ return distance;
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var deleted = await _employeeFacilityDistanceRepository.SoftDeleteAsync(id, cancellationToken);
+ if (deleted)
+ {
+ await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ return deleted;
+ }
+
+ public Task> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
+ => _employeeFacilityDistanceRepository.GetDeletedAsync(search, cancellationToken);
+
+ public async Task RestoreAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var restored = await _employeeFacilityDistanceRepository.RestoreAsync(id, cancellationToken);
+ if (restored)
+ {
+ await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ return restored;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/FacilityService.cs b/omsorgCore/src/OmsorgCore.Application/Services/FacilityService.cs
index e1e097e..f2e3842 100644
--- a/omsorgCore/src/OmsorgCore.Application/Services/FacilityService.cs
+++ b/omsorgCore/src/OmsorgCore.Application/Services/FacilityService.cs
@@ -55,6 +55,19 @@ public class FacilityService : IFacilityService
facility.BillingCountry = updates.BillingCountry;
facility.CrmStatus = updates.CrmStatus;
facility.FollowUpDueDate = updates.FollowUpDueDate;
+ facility.BillingRate = updates.BillingRate;
+ facility.NightSurchargePercent = updates.NightSurchargePercent;
+ facility.SaturdaySurchargePercent = updates.SaturdaySurchargePercent;
+ facility.SundaySurchargePercent = updates.SundaySurchargePercent;
+ facility.HolidaySurchargePercent = updates.HolidaySurchargePercent;
+ facility.TravelCostRate = updates.TravelCostRate;
+ facility.TravelCostMode = updates.TravelCostMode;
+ facility.TravelCostPerKm = updates.TravelCostPerKm;
+ facility.MinimumHours = updates.MinimumHours;
+ facility.BreakPolicy = updates.BreakPolicy;
+ facility.BillingInterval = updates.BillingInterval;
+ facility.PaymentTermDays = updates.PaymentTermDays;
+ facility.IndividualAgreements = updates.IndividualAgreements;
facility.UpdatedAt = DateTime.UtcNow;
await _facilityRepository.UpdateAsync(facility, cancellationToken);
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeFacilityDistanceService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeFacilityDistanceService.cs
new file mode 100644
index 0000000..77ae532
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeFacilityDistanceService.cs
@@ -0,0 +1,15 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public interface IEmployeeFacilityDistanceService
+{
+ Task> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default);
+ Task CreateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
+ Task UpdateAsync(Guid id, EmployeeFacilityDistance updates, CancellationToken cancellationToken = default);
+ Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
+ Task RestoreAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs
index 889c52e..f085fd8 100644
--- a/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs
@@ -16,4 +16,7 @@ public interface IRoleService
Guid roleId,
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
CancellationToken cancellationToken = default);
+
+ /// Löschschutz: schlägt fehl, solange mindestens ein User dieser Rolle zugewiesen ist (analog ValueListService.DeleteItemAsync).
+ Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs
index cef4cab..e914e3a 100644
--- a/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs
+++ b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs
@@ -8,11 +8,13 @@ public class RoleService : IRoleService
{
private readonly IRoleRepository _roleRepository;
private readonly IPermissionService _permissionService;
+ private readonly IUserRepository _userRepository;
- public RoleService(IRoleRepository roleRepository, IPermissionService permissionService)
+ public RoleService(IRoleRepository roleRepository, IPermissionService permissionService, IUserRepository userRepository)
{
_roleRepository = roleRepository;
_permissionService = permissionService;
+ _userRepository = userRepository;
}
public Task> GetAllAsync(CancellationToken cancellationToken = default)
@@ -62,4 +64,23 @@ public class RoleService : IRoleService
await _permissionService.InvalidateRolePermissionsAsync(roleId, cancellationToken);
return UpdateRolePermissionsResult.Ok();
}
+
+ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var role = await _roleRepository.GetByIdAsync(id, cancellationToken);
+ if (role is null)
+ {
+ return DeleteRoleResult.Fail(DeleteRoleFailureReason.RoleNotFound);
+ }
+
+ var assignedUserIds = await _userRepository.GetUserIdsByRoleAsync(id, cancellationToken);
+ if (assignedUserIds.Count > 0)
+ {
+ return DeleteRoleResult.Fail(DeleteRoleFailureReason.InUse, assignedUserIds.Count);
+ }
+
+ await _roleRepository.RemoveAsync(role, cancellationToken);
+ await _roleRepository.SaveChangesAsync(cancellationToken);
+ return DeleteRoleResult.Ok();
+ }
}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/EmployeeFacilityDistance.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/EmployeeFacilityDistance.cs
new file mode 100644
index 0000000..74fb992
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/EmployeeFacilityDistance.cs
@@ -0,0 +1,21 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Entfernung (km) zwischen einem Mitarbeiter und einer Einrichtung — Grundlage für die
+/// kilometerbasierte Fahrtkostenabrechnung ( == "ProKilometer",
+/// FR-EIN-4). Pro Mitarbeiter/Einrichtung individuell statt an Facility allein, da jeder Mitarbeiter
+/// von einem anderen Wohnort anfährt. 1:n-Unterressource von Facility, kein eigenständiges Core-Objekt
+/// — exakt nach dem Muster von .
+///
+public class EmployeeFacilityDistance : AuditableEntity
+{
+ public Guid FacilityId { get; set; }
+ public Facility Facility { get; set; } = null!;
+
+ public Guid EmployeeId { get; set; }
+ public Employee Employee { get; set; } = null!;
+
+ public decimal DistanceKm { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs
index f854514..ae85fe3 100644
--- a/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs
@@ -33,7 +33,12 @@ public class Facility : AuditableEntity
public decimal? SaturdaySurchargePercent { get; set; }
public decimal? SundaySurchargePercent { get; set; }
public decimal? HolidaySurchargePercent { get; set; }
+ // TravelCostMode entscheidet, welches der beiden Felder die Rechnungserstellung (FR-RE-1,
+ // sobald umgesetzt) verwendet: "Pauschale" -> TravelCostRate (EUR je Einsatz), "ProKilometer"
+ // -> TravelCostPerKm (EUR/km) * EmployeeFacilityDistance.DistanceKm des jeweiligen Mitarbeiters.
+ public string TravelCostMode { get; set; } = "Pauschale";
public decimal? TravelCostRate { get; set; }
+ public decimal? TravelCostPerKm { get; set; }
public decimal? MinimumHours { get; set; }
public string? BreakPolicy { get; set; }
public string? BillingInterval { get; set; }
diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs
index 4256f91..baa30bc 100644
--- a/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs
+++ b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs
@@ -18,5 +18,6 @@ public enum ModuleType
Documents,
Users,
Configuration,
- Absences
+ Absences,
+ EmployeeFacilityDistances
}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs
index 6923918..74e2449 100644
--- a/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs
@@ -35,6 +35,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeFacilityDistanceConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeFacilityDistanceConfiguration.cs
new file mode 100644
index 0000000..062a404
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeFacilityDistanceConfiguration.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class EmployeeFacilityDistanceConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("employee_facility_distances");
+ builder.HasKey(d => d.Id);
+ builder.Property(d => d.DistanceKm).IsRequired().HasColumnType("decimal(10,2)");
+
+ builder.HasOne(d => d.Facility)
+ .WithMany()
+ .HasForeignKey(d => d.FacilityId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(d => d.Employee)
+ .WithMany()
+ .HasForeignKey(d => d.EmployeeId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // Soft-gelöschte Distanzen sind für alle normalen Queries unsichtbar.
+ builder.HasQueryFilter(d => !d.IsDeleted);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs
index ceecde8..69ba559 100644
--- a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs
@@ -29,6 +29,8 @@ public class FacilityConfiguration : IEntityTypeConfiguration
builder.Property(f => f.SundaySurchargePercent).HasColumnType("decimal(5,2)");
builder.Property(f => f.HolidaySurchargePercent).HasColumnType("decimal(5,2)");
builder.Property(f => f.TravelCostRate).HasColumnType("decimal(10,2)");
+ builder.Property(f => f.TravelCostMode).IsRequired().HasMaxLength(20);
+ builder.Property(f => f.TravelCostPerKm).HasColumnType("decimal(10,2)");
builder.Property(f => f.MinimumHours).HasColumnType("decimal(5,2)");
builder.Property(f => f.BreakPolicy).HasMaxLength(1000);
builder.Property(f => f.BillingInterval).HasMaxLength(50);
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs
index 0ddb1e2..f808002 100644
--- a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs
@@ -69,7 +69,11 @@ public static class DbSeeder
/// User.EmployeeId. Ebenso Absences.{Create,View,Edit} mit PermissionScope.Own (FR-CON-1/FR-EM-3):
/// der Außendienst stellt eigene Abwesenheits-/Urlaubs-/Krankmeldungsanträge über
/// `omsorgWeb/mitarbeiter-app` gegen dieses Backend, Genehmigen/Ablehnen bleibt Büro-Rollen
- /// vorbehalten (kein Approve-Recht hier). "Geschäftsführung" bekommt AuditLog automatisch mit
+ /// vorbehalten (kein Approve-Recht hier). Ebenso EmployeeFacilityDistances.{Create,View,Edit} mit
+ /// PermissionScope.Own (FR-EIN-4, kilometerbasierte Fahrtkostenabrechnung, siehe
+ /// MyFacilityDistancesController) - der Außendienst pflegt seine eigenen Fahrtstrecken je
+ /// Einrichtung selbst über `omsorgWeb/mitarbeiter-app`, hat aber KEIN Facilities-Recht und sieht
+ /// daher keine Konditionen/CRM-Daten einer Einrichtung, nur deren Namen zur Auswahl. "Geschäftsführung" bekommt AuditLog automatisch mit
/// (iteriert alle ModuleType-Werte generisch,
/// siehe unten) - alle anderen Rollen listen ihre Module explizit auf und bekommen AuditLog dadurch
/// bewusst NICHT (Audit-Log ist per Default nur für Geschäftsführung sichtbar).
@@ -104,7 +108,8 @@ public static class DbSeeder
(ModuleType.Employees, new[] { PermissionAction.View }, PermissionScope.Own),
(ModuleType.Contracts, new[] { PermissionAction.View }, PermissionScope.Own),
(ModuleType.Absences, new[] { PermissionAction.Create, PermissionAction.View, PermissionAction.Edit }, PermissionScope.Own),
- (ModuleType.TimeEntries, new[] { PermissionAction.Create, PermissionAction.View, PermissionAction.Edit }, PermissionScope.Own)
+ (ModuleType.TimeEntries, new[] { PermissionAction.Create, PermissionAction.View, PermissionAction.Edit }, PermissionScope.Own),
+ (ModuleType.EmployeeFacilityDistances, new[] { PermissionAction.Create, PermissionAction.View, PermissionAction.Edit }, PermissionScope.Own)
}, cancellationToken);
}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260810172125_AddTravelCostModeAndEmployeeFacilityDistances.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260810172125_AddTravelCostModeAndEmployeeFacilityDistances.Designer.cs
new file mode 100644
index 0000000..d3fada1
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260810172125_AddTravelCostModeAndEmployeeFacilityDistances.Designer.cs
@@ -0,0 +1,1392 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OmsorgCore.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace OmsorgCore.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(OmsorgCoreDbContext))]
+ [Migration("20260810172125_AddTravelCostModeAndEmployeeFacilityDistances")]
+ partial class AddTravelCostModeAndEmployeeFacilityDistances
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Absence", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AdminNote")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmployeeId")
+ .HasColumnType("uuid");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Reason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Substitute")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmployeeId");
+
+ b.ToTable("absences", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid");
+
+ b.Property("ActorUsername")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("Details")
+ .HasColumnType("jsonb");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IpAddress")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserId");
+
+ b.HasIndex("OccurredAtUtc");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_log_entries", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AllowancesDescription")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ContractType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmployeeId")
+ .HasColumnType("uuid");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("HourlyWage")
+ .HasPrecision(10, 2)
+ .HasColumnType("numeric(10,2)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("OvertimeRules")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ProbationPeriodMonths")
+ .HasColumnType("integer");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VacationDaysPerYear")
+ .HasColumnType("integer");
+
+ b.Property("WeeklyHours")
+ .HasPrecision(10, 2)
+ .HasColumnType("numeric(10,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmployeeId");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("contracts", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StorageKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UploadedByUserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UploadedByUserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("documents", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarFileName")
+ .HasColumnType("text");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmergencyContactName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmergencyContactPhone")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("EmergencyContactRelation")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("EmploymentType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("EntryDate")
+ .HasColumnType("date");
+
+ b.Property("ExitDate")
+ .HasColumnType("date");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("PostalCode")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("Qualification")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Street")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("employees", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.EmployeeFacilityDistance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DistanceKm")
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("EmployeeId")
+ .HasColumnType("uuid");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmployeeId");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("employee_facility_distances", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BillingCity")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("BillingCountry")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("BillingInterval")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BillingPostalCode")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("BillingRate")
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("BillingStreet")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BreakPolicy")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CrmStatus")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FacilityType")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("FollowUpDueDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HolidaySurchargePercent")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("IndividualAgreements")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("MinimumHours")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("NightSurchargePercent")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("PaymentTermDays")
+ .HasColumnType("integer");
+
+ b.Property("PostalCode")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("SaturdaySurchargePercent")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("Street")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SundaySurchargePercent")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("TravelCostMode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TravelCostPerKm")
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("TravelCostRate")
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Website")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.HasKey("Id");
+
+ b.ToTable("facilities", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.FacilityContact", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Department")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Email")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Role")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("facility_contacts", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.FacilityQualificationRate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Qualification")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Rate")
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("facility_qualification_rates", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BillingPeriodEnd")
+ .HasColumnType("date");
+
+ b.Property("BillingPeriodStart")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DueDate")
+ .HasColumnType("date");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("GrossAmount")
+ .HasColumnType("numeric(12,2)");
+
+ b.Property("InvoiceNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("NetAmount")
+ .HasColumnType("numeric(12,2)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.HasIndex("InvoiceNumber")
+ .IsUnique();
+
+ b.ToTable("invoices", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.LoginAttempt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttemptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IpAddress")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Succeeded")
+ .HasColumnType("boolean");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IpAddress", "AttemptedAt");
+
+ b.ToTable("login_attempts", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Conditions")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("FacilityContactId")
+ .HasColumnType("uuid");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Priority")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("RequiredHeadcount")
+ .HasColumnType("integer");
+
+ b.Property("RequiredQualification")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ShiftType")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("StatusId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityContactId");
+
+ b.HasIndex("FacilityId");
+
+ b.HasIndex("StatusId");
+
+ b.ToTable("orders", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer");
+
+ b.Property("CodeHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InvalidatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ResetTokenExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ResetTokenHash")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ResetTokenUsedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ResetTokenHash");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("password_reset_codes", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReplacedByTokenId")
+ .HasColumnType("uuid");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReplacedByTokenId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("refresh_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("roles", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .HasColumnType("integer");
+
+ b.Property("Module")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.Property("Scope")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId", "Module", "Action")
+ .IsUnique();
+
+ b.ToTable("role_permissions", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AdminNote")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property