Add Verpflegungsmehraufwand (VMA) as fixed facility condition field
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 17s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 6s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 18s

Adds MealAllowanceRate as a flat, nullable EUR amount on Facility, following
the same pattern as the existing TravelCostRate/BillingRate fields — a fixed
per-facility rate, not the statutory day-based VMA tiers, per explicit
request.

Both generated API clients are regenerated/rebuilt as required after any
Contract/Controller change (see omsorgCore/CLAUDE.md, "Generierte API-
Clients"). The PHP client regen also picks up the DELETE /api/roles/{id}
endpoint added in a previous backend change but never synced to this client
until now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 20:45:40 +02:00
co-authored by Claude Sonnet 5
parent 99b5e07390
commit 019b4286fd
23 changed files with 1820 additions and 138 deletions
+5 -5
View File
@@ -164,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. `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.
- 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") und `RemoveFacilityBreakPolicy` (löscht die Spalte `BreakPolicy` von `facilities` wieder) sind erzeugt (`dotnet build`/`dotnet test` grün), aber noch nicht gegen eine echte Instanz verifiziert.
## Build- und Run-Befehle
@@ -233,14 +233,14 @@ Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebt
## Konditionen einer Einrichtung (FR-EIN-4)
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`:
Zehn der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschläge, Fahrtkosten, Mindeststunden, 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`. **Pausenregelung wurde am 2026-08-10 wieder entfernt** (fachlich nicht gebraucht, `BreakPolicy` auf `Facility`/`UpdateFacilityRequest`/`FacilityResponse` gelöscht, Migration `RemoveFacilityBreakPolicy`, beide generierten Clients neu erzeugt, `FacilityForm.jsx`/`FacilityDetailPanel.jsx` bereinigt) — falls sie doch wieder gebraucht wird, ist das ein Neuanlegen, kein Wiederherstellen (Hard-Delete der Spalte, kein Soft-Delete-Konzept für Schema-Felder):
- `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).
- `BillingRate` (Verrechnungssatz, EUR/Std.), `NightSurchargePercent`/`SaturdaySurchargePercent`/`SundaySurchargePercent`/`HolidaySurchargePercent` (Zuschläge als **Prozent** auf den Verrechnungssatz, nicht als EUR-Betrag), `MealAllowanceRate` (Verpflegungsmehraufwand, EUR-Pauschale je Einsatz — bewusst kein tageweises/gesetzliches Stufenmodell wie beim echten VMA, sondern wie `TravelCostRate` ein einziger fixer Betrag je Einrichtung), `MinimumHours` (Mindeststunden je Einsatz), `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`.
- **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`/`BillingInterval`/`PaymentTermDays`/`IndividualAgreements` seit deren Einführung (damals inkl. `BreakPolicy`, seit 2026-08-10 entfernt, siehe oben). 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.
- Alle zwölf 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.
**Ausnahme "Qualifikationsabhängige Preise":** eine variable Liste (ein Satz je Qualifikationsstufe) lässt sich nicht als feste Spaltengruppe abbilden — dafür die neue Entität `FacilityQualificationRate` (1:n zu `Facility`, `Qualification` gegen die ValueList `"Qualification"` validiert — dieselbe Liste wie `Employee.Qualification`/`Order.RequiredQualification`, siehe "Wo welches Feld referenziert wird" oben) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`) — exakt nach dem Muster von `FacilityContact`, kein eigener `ModuleType`, gegated über dieselben `Facilities`-Rechte. Löschen ist Soft-Delete, über `TrashController` (`api/trash/facility-qualification-rates/...`) wiederherstellbar.
@@ -23,8 +23,8 @@ public record FacilityResponse(
decimal? TravelCostRate,
string TravelCostMode,
decimal? TravelCostPerKm,
decimal? MealAllowanceRate,
decimal? MinimumHours,
string? BreakPolicy,
string? BillingInterval,
int? PaymentTermDays,
string? IndividualAgreements);
@@ -22,8 +22,8 @@ public record UpdateFacilityRequest(
decimal? TravelCostRate,
string TravelCostMode,
decimal? TravelCostPerKm,
decimal? MealAllowanceRate,
decimal? MinimumHours,
string? BreakPolicy,
string? BillingInterval,
int? PaymentTermDays,
string? IndividualAgreements);
@@ -202,11 +202,6 @@ public class FacilitiesController : ControllerBase
return BadRequest(billingAddressError);
}
if (request.BreakPolicy is { Length: > 1000 })
{
return BadRequest("BreakPolicy darf maximal 1000 Zeichen lang sein.");
}
if (request.IndividualAgreements is { Length: > 2000 })
{
return BadRequest("IndividualAgreements darf maximal 2000 Zeichen lang sein.");
@@ -224,6 +219,7 @@ public class FacilitiesController : ControllerBase
if (request.BillingRate is < 0
|| request.TravelCostRate is < 0
|| request.TravelCostPerKm is < 0
|| request.MealAllowanceRate is < 0
|| request.MinimumHours is < 0
|| request.NightSurchargePercent is < 0
|| request.SaturdaySurchargePercent is < 0
@@ -252,8 +248,8 @@ public class FacilitiesController : ControllerBase
TravelCostRate = request.TravelCostRate,
TravelCostMode = request.TravelCostMode,
TravelCostPerKm = request.TravelCostPerKm,
MealAllowanceRate = request.MealAllowanceRate,
MinimumHours = request.MinimumHours,
BreakPolicy = request.BreakPolicy,
BillingInterval = request.BillingInterval,
PaymentTermDays = request.PaymentTermDays,
IndividualAgreements = request.IndividualAgreements,
@@ -330,8 +326,8 @@ public class FacilitiesController : ControllerBase
facility.TravelCostRate,
facility.TravelCostMode,
facility.TravelCostPerKm,
facility.MealAllowanceRate,
facility.MinimumHours,
facility.BreakPolicy,
facility.BillingInterval,
facility.PaymentTermDays,
facility.IndividualAgreements);
@@ -63,8 +63,8 @@ public class FacilityService : IFacilityService
facility.TravelCostRate = updates.TravelCostRate;
facility.TravelCostMode = updates.TravelCostMode;
facility.TravelCostPerKm = updates.TravelCostPerKm;
facility.MealAllowanceRate = updates.MealAllowanceRate;
facility.MinimumHours = updates.MinimumHours;
facility.BreakPolicy = updates.BreakPolicy;
facility.BillingInterval = updates.BillingInterval;
facility.PaymentTermDays = updates.PaymentTermDays;
facility.IndividualAgreements = updates.IndividualAgreements;
@@ -39,8 +39,8 @@ public class Facility : AuditableEntity
public string TravelCostMode { get; set; } = "Pauschale";
public decimal? TravelCostRate { get; set; }
public decimal? TravelCostPerKm { get; set; }
public decimal? MealAllowanceRate { get; set; }
public decimal? MinimumHours { get; set; }
public string? BreakPolicy { get; set; }
public string? BillingInterval { get; set; }
public int? PaymentTermDays { get; set; }
public string? IndividualAgreements { get; set; }
@@ -31,8 +31,8 @@ public class FacilityConfiguration : IEntityTypeConfiguration<Facility>
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.MealAllowanceRate).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);
builder.Property(f => f.IndividualAgreements).HasMaxLength(2000);
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OmsorgCore.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddFacilityMealAllowanceRate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "MealAllowanceRate",
table: "facilities",
type: "numeric(10,2)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MealAllowanceRate",
table: "facilities");
}
}
}
@@ -426,10 +426,6 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BreakPolicy")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
@@ -466,6 +462,9 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<decimal?>("MealAllowanceRate")
.HasColumnType("decimal(10,2)");
b.Property<decimal?>("MinimumHours")
.HasColumnType("decimal(5,2)");
@@ -103,8 +103,8 @@ public class FacilityServiceTests
TravelCostMode = "ProKilometer",
TravelCostRate = 10,
TravelCostPerKm = 0.42m,
MealAllowanceRate = 14,
MinimumHours = 4,
BreakPolicy = "30 Min. ab 6 Std.",
BillingInterval = "Monatlich",
PaymentTermDays = 14,
IndividualAgreements = "Sonderkonditionen Wochenende"
@@ -121,8 +121,8 @@ public class FacilityServiceTests
Assert.Equal("ProKilometer", updated.TravelCostMode);
Assert.Equal(10, updated.TravelCostRate);
Assert.Equal(0.42m, updated.TravelCostPerKm);
Assert.Equal(14, updated.MealAllowanceRate);
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);