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

- 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:
Felix Kemmler
2026-08-10 20:15:42 +02:00
co-authored by Claude Sonnet 5
parent 09dce2ab98
commit ffa2c4a9e7
120 changed files with 13433 additions and 36 deletions
@@ -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)
@@ -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>();
@@ -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);
}
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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>();
@@ -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);
}