Add kilometer-based Fahrtkosten billing, self-service distance entry, role deletion
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 16s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 19s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 16s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 19s
- Facility: TravelCostMode (Pauschale/ProKilometer) + TravelCostPerKm, alongside the existing flat rate; fixes FacilityService.UpdateAsync silently dropping all Konditionen fields on update. - New EmployeeFacilityDistance (Mitarbeiter x Einrichtung -> km) with full office-side CRUD in omsorgapp, plus a self-service endpoint/UI so field staff can maintain their own commute distance via OMSORG Connect (new ModuleType.EmployeeFacilityDistances, Own-scope, no Facilities access needed). - Roles can now be deleted (blocked with a 409 while still assigned to a user). - Fix employeesApi.js missing the Date-object conversion for dateOfBirth/entryDate/ exitDate that crashed employee creation whenever a date was filled in; add a clear/remove control for those date fields. - Requirements: flesh out FR-REC-3 with a staged Akquise cadence, add FR-REC-5/6 for CRM feature scope and success metrics. - Regenerate api-client-ts and api-client-php for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
09dce2ab98
commit
ffa2c4a9e7
+13
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateEmployeeFacilityDistanceRequest(
|
||||
Guid EmployeeId,
|
||||
decimal DistanceKm);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateMyFacilityDistanceRequest(
|
||||
Guid FacilityId,
|
||||
decimal DistanceKm);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record EmployeeFacilityDistanceResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
Guid EmployeeId,
|
||||
string EmployeeFirstName,
|
||||
string EmployeeLastName,
|
||||
decimal DistanceKm);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Minimale Einrichtungsauswahl für Selbstbedienungs-Formulare ohne Facilities-Recht (z. B.
|
||||
/// MyFacilityDistancesController) — bewusst nur Id/Name, keine Konditionen/CRM-Daten.
|
||||
/// </summary>
|
||||
public record FacilityOptionResponse(
|
||||
Guid Id,
|
||||
string Name);
|
||||
@@ -21,6 +21,8 @@ public record FacilityResponse(
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
string TravelCostMode,
|
||||
decimal? TravelCostPerKm,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record MyFacilityDistanceResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string FacilityName,
|
||||
decimal DistanceKm);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashEmployeeFacilityDistanceResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
Guid EmployeeId,
|
||||
decimal DistanceKm,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateEmployeeFacilityDistanceRequest(
|
||||
decimal DistanceKm);
|
||||
@@ -20,6 +20,8 @@ public record UpdateFacilityRequest(
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
string TravelCostMode,
|
||||
decimal? TravelCostPerKm,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateMyFacilityDistanceRequest(
|
||||
decimal DistanceKm);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ActionResult<IReadOnlyList<EmployeeFacilityDistanceResponse>>> 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<ActionResult<EmployeeFacilityDistanceResponse>> 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<ActionResult<EmployeeFacilityDistanceResponse>> 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<IActionResult> 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<ActionResult<IReadOnlyList<MyFacilityDistanceResponse>>> 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<MyFacilityDistanceResponse>();
|
||||
|
||||
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<ActionResult<IReadOnlyList<FacilityOptionResponse>>> 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<ActionResult<MyFacilityDistanceResponse>> 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<ActionResult<MyFacilityDistanceResponse>> 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;
|
||||
}
|
||||
@@ -84,4 +84,22 @@ public class RolesController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ActionResult<IReadOnlyList<TrashEmployeeFacilityDistanceResponse>>> 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<IActionResult> 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<ActionResult<IReadOnlyList<TrashAbsenceResponse>>> GetDeletedAbsences([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IEmployeeFacilityDistanceRepository
|
||||
{
|
||||
Task<EmployeeFacilityDistance?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<EmployeeFacilityDistance>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeFacilityDistance?> GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<EmployeeFacilityDistance>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -18,5 +18,6 @@ public interface IRoleRepository
|
||||
Task AddPermissionRangeAsync(IEnumerable<RolePermission> permissions, CancellationToken cancellationToken = default);
|
||||
Task<bool> 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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IFacilityService, FacilityService>();
|
||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||
services.AddScoped<IFacilityQualificationRateService, FacilityQualificationRateService>();
|
||||
services.AddScoped<IEmployeeFacilityDistanceService, EmployeeFacilityDistanceService>();
|
||||
services.AddScoped<IContractService, ContractService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IAbsenceService, AbsenceService>();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>Nur gesetzt bei <see cref="DeleteRoleFailureReason.InUse"/> - Anzahl der Benutzer, die dieser Rolle noch zugewiesen sind.</summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<IReadOnlyList<EmployeeFacilityDistance>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> _employeeFacilityDistanceRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _employeeFacilityDistanceRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default)
|
||||
=> _employeeFacilityDistanceRepository.GetByFacilityAndEmployeeAsync(facilityId, employeeId, cancellationToken);
|
||||
|
||||
public async Task<EmployeeFacilityDistance> CreateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _employeeFacilityDistanceRepository.AddAsync(distance, cancellationToken);
|
||||
await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
|
||||
return distance;
|
||||
}
|
||||
|
||||
public async Task<EmployeeFacilityDistance?> 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<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _employeeFacilityDistanceRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EmployeeFacilityDistance>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _employeeFacilityDistanceRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _employeeFacilityDistanceRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _employeeFacilityDistanceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IEmployeeFacilityDistanceService
|
||||
{
|
||||
Task<IReadOnlyList<EmployeeFacilityDistance>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeFacilityDistance?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeFacilityDistance?> GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeFacilityDistance> CreateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeFacilityDistance?> UpdateAsync(Guid id, EmployeeFacilityDistance updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<EmployeeFacilityDistance>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,4 +16,7 @@ public interface IRoleService
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Löschschutz: schlägt fehl, solange mindestens ein User dieser Rolle zugewiesen ist (analog ValueListService.DeleteItemAsync).</summary>
|
||||
Task<DeleteRoleResult> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -62,4 +64,23 @@ public class RoleService : IRoleService
|
||||
await _permissionService.InvalidateRolePermissionsAsync(roleId, cancellationToken);
|
||||
return UpdateRolePermissionsResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<DeleteRoleResult> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entfernung (km) zwischen einem Mitarbeiter und einer Einrichtung — Grundlage für die
|
||||
/// kilometerbasierte Fahrtkostenabrechnung (<see cref="Facility.TravelCostMode"/> == "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 <see cref="FacilityQualificationRate"/>.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -18,5 +18,6 @@ public enum ModuleType
|
||||
Documents,
|
||||
Users,
|
||||
Configuration,
|
||||
Absences
|
||||
Absences,
|
||||
EmployeeFacilityDistances
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IFacilityRepository, FacilityRepository>();
|
||||
services.AddScoped<IFacilityContactRepository, FacilityContactRepository>();
|
||||
services.AddScoped<IFacilityQualificationRateRepository, FacilityQualificationRateRepository>();
|
||||
services.AddScoped<IEmployeeFacilityDistanceRepository, EmployeeFacilityDistanceRepository>();
|
||||
services.AddScoped<IContractRepository, ContractRepository>();
|
||||
services.AddScoped<IOrderRepository, OrderRepository>();
|
||||
services.AddScoped<IAbsenceRepository, AbsenceRepository>();
|
||||
|
||||
+28
@@ -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<EmployeeFacilityDistance>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmployeeFacilityDistance> 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);
|
||||
}
|
||||
}
|
||||
+2
@@ -29,6 +29,8 @@ public class FacilityConfiguration : IEntityTypeConfiguration<Facility>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1392
File diff suppressed because it is too large
Load Diff
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OmsorgCore.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTravelCostModeAndEmployeeFacilityDistances : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TravelCostMode",
|
||||
table: "facilities",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Pauschale");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TravelCostPerKm",
|
||||
table: "facilities",
|
||||
type: "numeric(10,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "employee_facility_distances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FacilityId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EmployeeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DistanceKm = table.Column<decimal>(type: "numeric(10,2)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_employee_facility_distances", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_employee_facility_distances_employees_EmployeeId",
|
||||
column: x => x.EmployeeId,
|
||||
principalTable: "employees",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_employee_facility_distances_facilities_FacilityId",
|
||||
column: x => x.FacilityId,
|
||||
principalTable: "facilities",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_employee_facility_distances_EmployeeId",
|
||||
table: "employee_facility_distances",
|
||||
column: "EmployeeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_employee_facility_distances_FacilityId",
|
||||
table: "employee_facility_distances",
|
||||
column: "FacilityId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "employee_facility_distances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TravelCostMode",
|
||||
table: "facilities");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TravelCostPerKm",
|
||||
table: "facilities");
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -361,6 +361,42 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("employees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OmsorgCore.Domain.Entities.EmployeeFacilityDistance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("DistanceKm")
|
||||
.HasColumnType("decimal(10,2)");
|
||||
|
||||
b.Property<Guid>("EmployeeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FacilityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("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<Guid>("Id")
|
||||
@@ -458,6 +494,14 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
|
||||
b.Property<decimal?>("SundaySurchargePercent")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<string>("TravelCostMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<decimal?>("TravelCostPerKm")
|
||||
.HasColumnType("decimal(10,2)");
|
||||
|
||||
b.Property<decimal?>("TravelCostRate")
|
||||
.HasColumnType("decimal(10,2)");
|
||||
|
||||
@@ -1121,6 +1165,25 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("UploadedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OmsorgCore.Domain.Entities.EmployeeFacilityDistance", b =>
|
||||
{
|
||||
b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee")
|
||||
.WithMany()
|
||||
.HasForeignKey("EmployeeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility")
|
||||
.WithMany()
|
||||
.HasForeignKey("FacilityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Employee");
|
||||
|
||||
b.Navigation("Facility");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OmsorgCore.Domain.Entities.FacilityContact", b =>
|
||||
{
|
||||
b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility")
|
||||
|
||||
@@ -13,6 +13,7 @@ public class OmsorgCoreDbContext : DbContext
|
||||
public DbSet<Facility> Facilities => Set<Facility>();
|
||||
public DbSet<FacilityContact> FacilityContacts => Set<FacilityContact>();
|
||||
public DbSet<FacilityQualificationRate> FacilityQualificationRates => Set<FacilityQualificationRate>();
|
||||
public DbSet<EmployeeFacilityDistance> EmployeeFacilityDistances => Set<EmployeeFacilityDistance>();
|
||||
public DbSet<Contract> Contracts => Set<Contract>();
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
public DbSet<Absence> Absences => Set<Absence>();
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Infrastructure.Persistence;
|
||||
|
||||
namespace OmsorgCore.Infrastructure.Repositories;
|
||||
|
||||
public class EmployeeFacilityDistanceRepository : IEmployeeFacilityDistanceRepository
|
||||
{
|
||||
private readonly OmsorgCoreDbContext _db;
|
||||
|
||||
public EmployeeFacilityDistanceRepository(OmsorgCoreDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _db.EmployeeFacilityDistances.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<EmployeeFacilityDistance>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> await _db.EmployeeFacilityDistances
|
||||
.AsNoTracking()
|
||||
.Include(d => d.Employee)
|
||||
.Where(d => d.FacilityId == facilityId)
|
||||
.OrderBy(d => d.Employee.LastName)
|
||||
.ThenBy(d => d.Employee.FirstName)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default)
|
||||
=> _db.EmployeeFacilityDistances.FirstOrDefaultAsync(d => d.FacilityId == facilityId && d.EmployeeId == employeeId, cancellationToken);
|
||||
|
||||
public async Task AddAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
|
||||
=> await _db.EmployeeFacilityDistances.AddAsync(distance, cancellationToken);
|
||||
|
||||
public Task UpdateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_db.EmployeeFacilityDistances.Update(distance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distance = await _db.EmployeeFacilityDistances.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
|
||||
if (distance is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
distance.IsDeleted = true;
|
||||
distance.DeletedAt = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmployeeFacilityDistance>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.EmployeeFacilityDistances.IgnoreQueryFilters().AsNoTracking().Include(d => d.Employee).Where(d => d.IsDeleted);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var pattern = $"%{search.Trim()}%";
|
||||
query = query.Where(d => EF.Functions.ILike(d.Employee.FirstName, pattern) || EF.Functions.ILike(d.Employee.LastName, pattern));
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(d => d.DeletedAt).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distance = await _db.EmployeeFacilityDistances.IgnoreQueryFilters().FirstOrDefaultAsync(d => d.Id == id && d.IsDeleted, cancellationToken);
|
||||
if (distance is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
distance.IsDeleted = false;
|
||||
distance.DeletedAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -34,6 +34,12 @@ public class RoleRepository : IRoleRepository
|
||||
public async Task AddAsync(Role role, CancellationToken cancellationToken = default)
|
||||
=> await _db.Roles.AddAsync(role, cancellationToken);
|
||||
|
||||
public Task RemoveAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_db.Roles.Remove(role);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Tests.TestDoubles;
|
||||
|
||||
namespace OmsorgCore.Tests.Services;
|
||||
|
||||
public class EmployeeFacilityDistanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsAllFields()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var facilityId = Guid.NewGuid();
|
||||
var employeeId = Guid.NewGuid();
|
||||
|
||||
var distance = new EmployeeFacilityDistance
|
||||
{
|
||||
FacilityId = facilityId,
|
||||
EmployeeId = employeeId,
|
||||
DistanceKm = 12.5m
|
||||
};
|
||||
|
||||
var created = await sut.CreateAsync(distance);
|
||||
|
||||
Assert.Equal(facilityId, created.FacilityId);
|
||||
Assert.Equal(employeeId, created.EmployeeId);
|
||||
Assert.Equal(12.5m, created.DistanceKm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFacilityIdAsync_ReturnsOnlyDistancesOfThatFacility()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var facilityIdA = Guid.NewGuid();
|
||||
var facilityIdB = Guid.NewGuid();
|
||||
|
||||
await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = facilityIdA, EmployeeId = Guid.NewGuid(), DistanceKm = 5 });
|
||||
await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = facilityIdA, EmployeeId = Guid.NewGuid(), DistanceKm = 8 });
|
||||
await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = facilityIdB, EmployeeId = Guid.NewGuid(), DistanceKm = 3 });
|
||||
|
||||
var distancesForA = await sut.GetByFacilityIdAsync(facilityIdA);
|
||||
|
||||
Assert.Equal(2, distancesForA.Count);
|
||||
Assert.All(distancesForA, d => Assert.Equal(facilityIdA, d.FacilityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFacilityAndEmployeeAsync_FindsExistingPair()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var facilityId = Guid.NewGuid();
|
||||
var employeeId = Guid.NewGuid();
|
||||
|
||||
await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = facilityId, EmployeeId = employeeId, DistanceKm = 7 });
|
||||
|
||||
var found = await sut.GetByFacilityAndEmployeeAsync(facilityId, employeeId);
|
||||
var notFound = await sut.GetByFacilityAndEmployeeAsync(facilityId, Guid.NewGuid());
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(7, found!.DistanceKm);
|
||||
Assert.Null(notFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UpdatesDistanceKm_AndReturnsUpdatedEntity()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var created = await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = Guid.NewGuid(), EmployeeId = Guid.NewGuid(), DistanceKm = 5 });
|
||||
|
||||
var updated = await sut.UpdateAsync(created.Id, new EmployeeFacilityDistance { DistanceKm = 9.5m });
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(9.5m, updated!.DistanceKm);
|
||||
Assert.NotNull(updated.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
|
||||
var updated = await sut.UpdateAsync(Guid.NewGuid(), new EmployeeFacilityDistance { DistanceKm = 1 });
|
||||
|
||||
Assert.Null(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ThenGetDeletedAsync_ReturnsSoftDeletedDistance()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var created = await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = Guid.NewGuid(), EmployeeId = Guid.NewGuid(), DistanceKm = 5 });
|
||||
|
||||
var deleted = await sut.DeleteAsync(created.Id);
|
||||
var deletedList = await sut.GetDeletedAsync(null);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.Contains(deletedList, d => d.Id == created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_UndoesSoftDelete()
|
||||
{
|
||||
var repository = new FakeEmployeeFacilityDistanceRepository();
|
||||
var sut = new EmployeeFacilityDistanceService(repository);
|
||||
var created = await sut.CreateAsync(new EmployeeFacilityDistance { FacilityId = Guid.NewGuid(), EmployeeId = Guid.NewGuid(), DistanceKm = 5 });
|
||||
await sut.DeleteAsync(created.Id);
|
||||
|
||||
var restored = await sut.RestoreAsync(created.Id);
|
||||
var deletedList = await sut.GetDeletedAsync(null);
|
||||
|
||||
Assert.True(restored);
|
||||
Assert.DoesNotContain(deletedList, d => d.Id == created.Id);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,50 @@ public class FacilityServiceTests
|
||||
Assert.NotNull(updated.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsKonditionenFields()
|
||||
{
|
||||
var repository = new FakeFacilityRepository();
|
||||
var sut = new FacilityService(repository);
|
||||
var created = await sut.CreateAsync(new Facility { Name = "Pflegeheim Musterstadt" });
|
||||
|
||||
var updates = new Facility
|
||||
{
|
||||
Name = "Pflegeheim Musterstadt",
|
||||
CrmStatus = "Kunde",
|
||||
BillingRate = 32.5m,
|
||||
NightSurchargePercent = 25,
|
||||
SaturdaySurchargePercent = 15,
|
||||
SundaySurchargePercent = 20,
|
||||
HolidaySurchargePercent = 50,
|
||||
TravelCostMode = "ProKilometer",
|
||||
TravelCostRate = 10,
|
||||
TravelCostPerKm = 0.42m,
|
||||
MinimumHours = 4,
|
||||
BreakPolicy = "30 Min. ab 6 Std.",
|
||||
BillingInterval = "Monatlich",
|
||||
PaymentTermDays = 14,
|
||||
IndividualAgreements = "Sonderkonditionen Wochenende"
|
||||
};
|
||||
|
||||
var updated = await sut.UpdateAsync(created.Id, updates);
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(32.5m, updated!.BillingRate);
|
||||
Assert.Equal(25, updated.NightSurchargePercent);
|
||||
Assert.Equal(15, updated.SaturdaySurchargePercent);
|
||||
Assert.Equal(20, updated.SundaySurchargePercent);
|
||||
Assert.Equal(50, updated.HolidaySurchargePercent);
|
||||
Assert.Equal("ProKilometer", updated.TravelCostMode);
|
||||
Assert.Equal(10, updated.TravelCostRate);
|
||||
Assert.Equal(0.42m, updated.TravelCostPerKm);
|
||||
Assert.Equal(4, updated.MinimumHours);
|
||||
Assert.Equal("30 Min. ab 6 Std.", updated.BreakPolicy);
|
||||
Assert.Equal("Monatlich", updated.BillingInterval);
|
||||
Assert.Equal(14, updated.PaymentTermDays);
|
||||
Assert.Equal("Sonderkonditionen Wochenende", updated.IndividualAgreements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ public class RoleServiceTests
|
||||
public async Task CreateAsync_WithNewName_CreatesRole()
|
||||
{
|
||||
var roles = new FakeRoleRepository();
|
||||
var sut = new RoleService(roles, CreatePermissionService());
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.CreateAsync("Aussendienst");
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RoleServiceTests
|
||||
public async Task CreateAsync_WithExistingName_Fails()
|
||||
{
|
||||
var roles = new FakeRoleRepository(new Role { Name = "Aussendienst" });
|
||||
var sut = new RoleService(roles, CreatePermissionService());
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.CreateAsync("Aussendienst");
|
||||
|
||||
@@ -40,7 +40,7 @@ public class RoleServiceTests
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEmptyName_Fails()
|
||||
{
|
||||
var sut = new RoleService(new FakeRoleRepository(), CreatePermissionService());
|
||||
var sut = new RoleService(new FakeRoleRepository(), CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.CreateAsync(" ");
|
||||
|
||||
@@ -54,7 +54,7 @@ public class RoleServiceTests
|
||||
var role = new Role { Name = "Disposition" };
|
||||
role.RolePermissions.Add(new RolePermission { RoleId = role.Id, Role = role, Module = ModuleType.Employees, Action = PermissionAction.View });
|
||||
var roles = new FakeRoleRepository(role);
|
||||
var sut = new RoleService(roles, CreatePermissionService());
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.UpdatePermissionsAsync(role.Id, new[] { (ModuleType.Invoices, PermissionAction.Edit, PermissionScope.All) });
|
||||
|
||||
@@ -71,7 +71,7 @@ public class RoleServiceTests
|
||||
{
|
||||
var role = new Role { Name = "Außendienst" };
|
||||
var roles = new FakeRoleRepository(role);
|
||||
var sut = new RoleService(roles, CreatePermissionService());
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.UpdatePermissionsAsync(role.Id, new[] { (ModuleType.Employees, PermissionAction.View, PermissionScope.Own) });
|
||||
|
||||
@@ -84,11 +84,51 @@ public class RoleServiceTests
|
||||
[Fact]
|
||||
public async Task UpdatePermissionsAsync_UnknownRole_Fails()
|
||||
{
|
||||
var sut = new RoleService(new FakeRoleRepository(), CreatePermissionService());
|
||||
var sut = new RoleService(new FakeRoleRepository(), CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.UpdatePermissionsAsync(Guid.NewGuid(), new[] { (ModuleType.Employees, PermissionAction.View, PermissionScope.All) });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(UpdateRolePermissionsFailureReason.RoleNotFound, result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_UnassignedRole_DeletesIt()
|
||||
{
|
||||
var role = new Role { Name = "Recruiting" };
|
||||
var roles = new FakeRoleRepository(role);
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.DeleteAsync(role.Id);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(await roles.GetAllAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RoleStillAssignedToUser_Fails()
|
||||
{
|
||||
var role = new Role { Name = "Recruiting" };
|
||||
var roles = new FakeRoleRepository(role);
|
||||
var user = new User { Username = "sascha", RoleId = role.Id };
|
||||
var sut = new RoleService(roles, CreatePermissionService(), new FakeUserRepository(user));
|
||||
|
||||
var result = await sut.DeleteAsync(role.Id);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(DeleteRoleFailureReason.InUse, result.FailureReason);
|
||||
Assert.Equal(1, result.AssignedUserCount);
|
||||
Assert.Single(await roles.GetAllAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_UnknownRole_Fails()
|
||||
{
|
||||
var sut = new RoleService(new FakeRoleRepository(), CreatePermissionService(), new FakeUserRepository());
|
||||
|
||||
var result = await sut.DeleteAsync(Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(DeleteRoleFailureReason.RoleNotFound, result.FailureReason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,63 @@ public class FakeFacilityQualificationRateRepository : IFacilityQualificationRat
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
public class FakeEmployeeFacilityDistanceRepository : IEmployeeFacilityDistanceRepository
|
||||
{
|
||||
private readonly List<EmployeeFacilityDistance> _distances = new();
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_distances.FirstOrDefault(d => d.Id == id));
|
||||
|
||||
public Task<IReadOnlyList<EmployeeFacilityDistance>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<EmployeeFacilityDistance>>(
|
||||
_distances.Where(d => d.FacilityId == facilityId).ToList());
|
||||
|
||||
public Task<EmployeeFacilityDistance?> GetByFacilityAndEmployeeAsync(Guid facilityId, Guid employeeId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_distances.FirstOrDefault(d => d.FacilityId == facilityId && d.EmployeeId == employeeId));
|
||||
|
||||
public Task AddAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_distances.Add(distance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(EmployeeFacilityDistance distance, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distance = _distances.FirstOrDefault(d => d.Id == id);
|
||||
if (distance is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
distance.IsDeleted = true;
|
||||
distance.DeletedAt = DateTime.UtcNow;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EmployeeFacilityDistance>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<EmployeeFacilityDistance>>(
|
||||
_distances.Where(d => d.IsDeleted).OrderByDescending(d => d.DeletedAt).ToList());
|
||||
|
||||
public Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distance = _distances.FirstOrDefault(d => d.Id == id && d.IsDeleted);
|
||||
if (distance is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
distance.IsDeleted = false;
|
||||
distance.DeletedAt = null;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
public class FakeContractRepository : IContractRepository
|
||||
{
|
||||
private readonly List<Contract> _contracts = new();
|
||||
@@ -505,6 +562,12 @@ public class FakeRoleRepository : IRoleRepository
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_roles.RemoveAll(r => r.Id == role.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AddPermissionRangeAsync(IEnumerable<RolePermission> permissions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var permission in permissions)
|
||||
|
||||
Reference in New Issue
Block a user