Einrichtung: Konditionen und CRM-Status beim Anlegen direkt sichtbar machen
- Backend (omsorgCore): CreateFacilityRequest um Konditionen- und CRM-Status-Felder erweitert, identisch mit UpdateFacilityRequest - FacilitiesController.Create: Gleiche Validierung wie Update (CrmStatus-Allowlist, FollowUp-Logik, Konditions-Wertebereich), aber ohne Transition-Check (kein Vorzustand beim Anlegen) - Gemeinsame Validierungslogik in ValidateCrmStatusAndFollowUpAsync extrahiert, um Code-Duplizierung zu vermeiden - omsorgCore/CLAUDE.md aktualisiert: Konditionen sind jetzt auch beim Anlegen setzbar - API-Clients neu generiert (TypeScript und PHP) - Frontend (omsorgapp): FacilityForm.jsx: includeCrmStatus-Flag entfernt, Konditionen und CRM-Status immer sichtbar - CreateFacilityDialog.jsx: CRM-Status-Auswahl + FollowUpDaysDialog-Workflow (analog zu Edit-Dialog) - EditFacilityDialog.jsx: vereinfacht (includeCrmStatus-Prop entfernt) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
52fbec3214
commit
e1fbfadd17
@@ -240,7 +240,7 @@ Zehn der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschlä
|
||||
- **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`/`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 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.
|
||||
- Alle zwölf Felder sind **auch beim Anlegen** über `CreateFacilityRequest` setzbar (zusätzlich zu `UpdateFacilityRequest`). `CreateFacilityRequest` enthält die gleichen Konditionsfelder wie `UpdateFacilityRequest` — `CrmStatus` ist Pflicht beim Anlegen. Die Validierung für diese Felder (Wertebereich, Allowlists, FollowUp-Logik) läuft in beiden Cases, nur die `CanTransitionAsync`-Prüfung ("erlauber Statuswechsel") entfällt beim Anlegen, da es dort keinen Vorzustand gibt — jeder aktive CRM-Status ist beim Anlegen wählbar.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityRequest(
|
||||
string Name,
|
||||
string CrmStatus,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
@@ -11,4 +12,18 @@ public record CreateFacilityRequest(
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
string? BillingCountry,
|
||||
int? FollowUpDays,
|
||||
decimal? BillingRate,
|
||||
decimal? NightSurchargePercent,
|
||||
decimal? SaturdaySurchargePercent,
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
string TravelCostMode,
|
||||
decimal? TravelCostPerKm,
|
||||
decimal? MealAllowanceRate,
|
||||
decimal? MinimumHours,
|
||||
string? BillingInterval,
|
||||
int? PaymentTermDays,
|
||||
string? IndividualAgreements);
|
||||
|
||||
@@ -15,6 +15,8 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/facilities")]
|
||||
public class FacilitiesController : ControllerBase
|
||||
{
|
||||
private record CrmStatusValidationResult(string? ErrorMessage, DateTime? FollowUpDueDate);
|
||||
|
||||
private const string CrmStatusListKey = "CrmStatus";
|
||||
private const string FacilityTypeListKey = "FacilityType";
|
||||
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
|
||||
@@ -66,6 +68,11 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CrmStatus) || request.CrmStatus.Length > 50)
|
||||
{
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
{
|
||||
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
|
||||
@@ -97,9 +104,67 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
var crmStatusValidationError = await ValidateCrmStatusAndFollowUpAsync(
|
||||
request.CrmStatus,
|
||||
request.FollowUpDays,
|
||||
existingCrmStatus: null,
|
||||
cancellationToken);
|
||||
if (crmStatusValidationError.ErrorMessage is not null)
|
||||
{
|
||||
return BadRequest(crmStatusValidationError.ErrorMessage);
|
||||
}
|
||||
|
||||
if (request.IndividualAgreements is { Length: > 2000 })
|
||||
{
|
||||
return BadRequest("IndividualAgreements darf maximal 2000 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.BillingInterval is not null)
|
||||
{
|
||||
var allowedBillingIntervals = await _valueListRepository.GetActiveValuesAsync(BillingIntervalListKey, cancellationToken);
|
||||
if (!allowedBillingIntervals.Contains(request.BillingInterval))
|
||||
{
|
||||
return BadRequest($"BillingInterval muss einer der folgenden Werte sein: {string.Join(", ", allowedBillingIntervals)}.");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|| request.SundaySurchargePercent is < 0
|
||||
|| request.HolidaySurchargePercent is < 0
|
||||
|| request.PaymentTermDays is < 0)
|
||||
{
|
||||
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 facility = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
CrmStatus = request.CrmStatus,
|
||||
FollowUpDueDate = crmStatusValidationError.FollowUpDueDate,
|
||||
BillingRate = request.BillingRate,
|
||||
NightSurchargePercent = request.NightSurchargePercent,
|
||||
SaturdaySurchargePercent = request.SaturdaySurchargePercent,
|
||||
SundaySurchargePercent = request.SundaySurchargePercent,
|
||||
HolidaySurchargePercent = request.HolidaySurchargePercent,
|
||||
TravelCostRate = request.TravelCostRate,
|
||||
TravelCostMode = request.TravelCostMode,
|
||||
TravelCostPerKm = request.TravelCostPerKm,
|
||||
MealAllowanceRate = request.MealAllowanceRate,
|
||||
MinimumHours = request.MinimumHours,
|
||||
BillingInterval = request.BillingInterval,
|
||||
PaymentTermDays = request.PaymentTermDays,
|
||||
IndividualAgreements = request.IndividualAgreements,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
@@ -138,37 +203,20 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
|
||||
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == request.CrmStatus);
|
||||
if (selectedCrmStatusItem is null)
|
||||
var crmStatusValidationError = await ValidateCrmStatusAndFollowUpAsync(
|
||||
request.CrmStatus,
|
||||
request.FollowUpDays,
|
||||
existingCrmStatus: existing.CrmStatus,
|
||||
cancellationToken);
|
||||
if (crmStatusValidationError.ErrorMessage is not null)
|
||||
{
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.");
|
||||
return BadRequest(crmStatusValidationError.ErrorMessage);
|
||||
}
|
||||
|
||||
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existing.CrmStatus);
|
||||
if (currentCrmStatusItem is not null
|
||||
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
|
||||
var followUpDueDate = crmStatusValidationError.FollowUpDueDate;
|
||||
if (followUpDueDate is null && existing.CrmStatus == request.CrmStatus)
|
||||
{
|
||||
return BadRequest("Der Statuswechsel ist nicht zulässig.");
|
||||
}
|
||||
|
||||
DateTime? followUpDueDate = null;
|
||||
if (selectedCrmStatusItem.TriggersFollowUp)
|
||||
{
|
||||
if (existing.CrmStatus == request.CrmStatus)
|
||||
{
|
||||
followUpDueDate = existing.FollowUpDueDate;
|
||||
}
|
||||
else
|
||||
{
|
||||
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
|
||||
if (request.FollowUpDays is null || !allowedFollowUpPeriods.Contains(request.FollowUpDays.Value.ToString()))
|
||||
{
|
||||
return BadRequest($"FollowUpDays ist bei CrmStatus \"{request.CrmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.");
|
||||
}
|
||||
|
||||
followUpDueDate = DateTime.UtcNow.AddDays(request.FollowUpDays.Value);
|
||||
}
|
||||
followUpDueDate = existing.FollowUpDueDate;
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
@@ -277,6 +325,51 @@ public class FacilitiesController : ControllerBase
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<CrmStatusValidationResult> ValidateCrmStatusAndFollowUpAsync(
|
||||
string crmStatus,
|
||||
int? followUpDays,
|
||||
string? existingCrmStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
|
||||
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == crmStatus);
|
||||
if (selectedCrmStatusItem is null)
|
||||
{
|
||||
return new($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.", null);
|
||||
}
|
||||
|
||||
if (existingCrmStatus is not null)
|
||||
{
|
||||
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existingCrmStatus);
|
||||
if (currentCrmStatusItem is not null
|
||||
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
|
||||
{
|
||||
return new("Der Statuswechsel ist nicht zulässig.", null);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? followUpDueDate = null;
|
||||
if (selectedCrmStatusItem.TriggersFollowUp)
|
||||
{
|
||||
if (existingCrmStatus is not null && existingCrmStatus == crmStatus)
|
||||
{
|
||||
followUpDueDate = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
|
||||
if (followUpDays is null || !allowedFollowUpPeriods.Contains(followUpDays.Value.ToString()))
|
||||
{
|
||||
return new($"FollowUpDays ist bei CrmStatus \"{crmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.", null);
|
||||
}
|
||||
|
||||
followUpDueDate = DateTime.UtcNow.AddDays(followUpDays.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return new(null, followUpDueDate);
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
|
||||
Reference in New Issue
Block a user