Add employee-to-order assignments with FR-EM-3 conflict validation
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 17s

Adds the Assignment core object (Order x Employee, date range) with full
CRUD, soft-delete/trash integration, and generated API clients.

Layers FR-EM-3 conflict checks onto assignment creation: qualification,
absence/availability, working-hours approximation, cross-order overlap,
and active-contract coverage. Each check's severity (Warning vs. Error)
is configurable at runtime via a new AssignmentValidationSettings
singleton and admin settings panel, instead of being hardcoded - lets
the business tune strictness per check without a redeploy.

Adds a live GET /api/assignments/check endpoint so the create-assignment
dialog can preview conflicts as the user picks employee/dates, before
they hit save, rather than only finding out after submitting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-11 00:44:14 +02:00
co-authored by Claude Sonnet 5
parent fcbf27db3c
commit dfeb37cf33
104 changed files with 15846 additions and 7 deletions
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record AssignmentConflictResponse(string Type, string Severity, string Message);
@@ -0,0 +1,12 @@
namespace OmsorgCore.Api.Contracts;
public record AssignmentResponse(
Guid Id,
Guid OrderId,
Guid EmployeeId,
string EmployeeFirstName,
string EmployeeLastName,
DateOnly StartDate,
DateOnly EndDate,
string? Note,
IReadOnlyList<AssignmentConflictResponse> Conflicts);
@@ -0,0 +1,8 @@
namespace OmsorgCore.Api.Contracts;
public record AssignmentValidationSettingsResponse(
string QualificationMode,
string AbsenceMode,
string WorkingHoursMode,
string OverlapMode,
string ContractMode);
@@ -0,0 +1,8 @@
namespace OmsorgCore.Api.Contracts;
public record CreateAssignmentRequest(
Guid OrderId,
Guid EmployeeId,
DateOnly StartDate,
DateOnly EndDate,
string? Note = null);
@@ -0,0 +1,7 @@
namespace OmsorgCore.Api.Contracts;
public record TrashAssignmentResponse(
Guid Id,
string EmployeeFirstName,
string EmployeeLastName,
DateTime? DeletedAt);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record UpdateAssignmentRequest(string? Note = null);
@@ -0,0 +1,8 @@
namespace OmsorgCore.Api.Contracts;
public record UpdateAssignmentValidationSettingsRequest(
string QualificationMode,
string AbsenceMode,
string WorkingHoursMode,
string OverlapMode,
string ContractMode);
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Services;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Api.Controllers;
/// <summary>
/// Admin-Konfiguration der FR-EM-3-Konfliktprüfungen bei der Mitarbeiterzuweisung: pro Prüfung
/// (Qualifikation/Abwesenheit/Arbeitszeit/Überschneidung/Vertrag) einzeln als Warning oder Error
/// einstellbar, siehe AssignmentService.CreateAsync.
/// </summary>
[ApiController]
[Authorize]
[Route("api/settings/assignment-validation")]
public class AssignmentValidationSettingsController : ControllerBase
{
private readonly IAssignmentValidationSettingsService _service;
public AssignmentValidationSettingsController(IAssignmentValidationSettingsService service)
{
_service = service;
}
[HttpGet]
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
public async Task<ActionResult<AssignmentValidationSettingsResponse>> Get(CancellationToken cancellationToken)
{
var settings = await _service.GetAsync(cancellationToken);
return Ok(new AssignmentValidationSettingsResponse(
settings.QualificationMode.ToString(),
settings.AbsenceMode.ToString(),
settings.WorkingHoursMode.ToString(),
settings.OverlapMode.ToString(),
settings.ContractMode.ToString()));
}
[HttpPut]
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
public async Task<ActionResult<AssignmentValidationSettingsResponse>> Update(
UpdateAssignmentValidationSettingsRequest request,
CancellationToken cancellationToken)
{
if (!TryParseSeverity(request.QualificationMode, out var qualificationMode) ||
!TryParseSeverity(request.AbsenceMode, out var absenceMode) ||
!TryParseSeverity(request.WorkingHoursMode, out var workingHoursMode) ||
!TryParseSeverity(request.OverlapMode, out var overlapMode) ||
!TryParseSeverity(request.ContractMode, out var contractMode))
{
return BadRequest(new { error = "invalid_severity", message = "Mode must be \"Warning\" or \"Error\"" });
}
var settings = await _service.UpdateAsync(qualificationMode, absenceMode, workingHoursMode, overlapMode, contractMode, cancellationToken);
return Ok(new AssignmentValidationSettingsResponse(
settings.QualificationMode.ToString(),
settings.AbsenceMode.ToString(),
settings.WorkingHoursMode.ToString(),
settings.OverlapMode.ToString(),
settings.ContractMode.ToString()));
}
private static bool TryParseSeverity(string value, out ValidationSeverity severity)
=> Enum.TryParse(value, ignoreCase: true, out severity);
}
@@ -0,0 +1,134 @@
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;
[ApiController]
[Authorize]
[Route("api/assignments")]
public class AssignmentsController : ControllerBase
{
private readonly IAssignmentService _assignmentService;
public AssignmentsController(IAssignmentService assignmentService)
{
_assignmentService = assignmentService;
}
[HttpGet]
[RequirePermission(ModuleType.Assignments, PermissionAction.View)]
public async Task<ActionResult<PagedResponse<AssignmentResponse>>> GetAll(
[FromQuery] Guid? orderId = null,
[FromQuery] Guid? employeeId = null,
[FromQuery] DateOnly? fromDate = null,
[FromQuery] DateOnly? toDate = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var (items, totalCount) = await _assignmentService.GetPagedAsync(
orderId, employeeId, fromDate, toDate, page, pageSize, cancellationToken);
var responses = items.Select(a => ToResponse(a)).ToList();
return Ok(new PagedResponse<AssignmentResponse>(responses, totalCount, page, pageSize));
}
/// <summary>
/// Läuft dieselben FR-EM-3-Konfliktprüfungen wie <see cref="Create"/>, ohne etwas anzulegen -
/// für die Live-Vorschau im Zuweisungs-Dialog, sobald Mitarbeiter/Zeitraum ausgewählt sind.
/// </summary>
[HttpGet("check")]
[RequirePermission(ModuleType.Assignments, PermissionAction.Create)]
public async Task<ActionResult<IReadOnlyList<AssignmentConflictResponse>>> Check(
[FromQuery] Guid orderId,
[FromQuery] Guid employeeId,
[FromQuery] DateOnly startDate,
[FromQuery] DateOnly endDate,
[FromQuery] Guid? excludeAssignmentId,
CancellationToken cancellationToken)
{
var conflicts = await _assignmentService.CheckConflictsAsync(
orderId, employeeId, startDate, endDate, excludeAssignmentId, cancellationToken);
return Ok(conflicts.Select(ToConflictResponse).ToList());
}
[HttpGet("{id:guid}")]
[RequirePermission(ModuleType.Assignments, PermissionAction.View)]
public async Task<ActionResult<AssignmentResponse>> GetById(Guid id, CancellationToken cancellationToken)
{
var assignment = await _assignmentService.GetByIdAsync(id, cancellationToken);
return assignment is null ? NotFound() : Ok(ToResponse(assignment));
}
[HttpPost]
[RequirePermission(ModuleType.Assignments, PermissionAction.Create)]
public async Task<ActionResult<AssignmentResponse>> Create(
CreateAssignmentRequest request,
CancellationToken cancellationToken)
{
var result = await _assignmentService.CreateAsync(
request.OrderId,
request.EmployeeId,
request.StartDate,
request.EndDate,
request.Note,
cancellationToken);
if (!result.Success)
{
return result.Reason switch
{
CreateAssignmentFailureReason.OrderNotFound => NotFound(new { error = "order_not_found" }),
CreateAssignmentFailureReason.EmployeeNotFound => NotFound(new { error = "employee_not_found" }),
CreateAssignmentFailureReason.InvalidDateRange => BadRequest(new { error = "invalid_date_range", message = "End date must be >= start date" }),
CreateAssignmentFailureReason.DateOutsideOrderRange => BadRequest(new { error = "date_outside_order_range", message = "Assignment dates must be within the order's date range" }),
CreateAssignmentFailureReason.OverlappingAssignment => Conflict(new { error = "overlapping_assignment", message = "Employee already has an assignment in this time range for this order" }),
CreateAssignmentFailureReason.ValidationConflict => Conflict(new { error = "validation_conflict", conflicts = result.Conflicts.Select(ToConflictResponse) }),
_ => BadRequest(new { error = "unknown_error" })
};
}
return CreatedAtAction(nameof(GetById), new { id = result.Assignment!.Id }, ToResponse(result.Assignment, result.Conflicts));
}
[HttpPut("{id:guid}")]
[RequirePermission(ModuleType.Assignments, PermissionAction.Edit)]
public async Task<ActionResult<AssignmentResponse>> Update(
Guid id,
UpdateAssignmentRequest request,
CancellationToken cancellationToken)
{
var assignment = await _assignmentService.UpdateAsync(id, request.Note, cancellationToken);
return assignment is null ? NotFound() : Ok(ToResponse(assignment));
}
[HttpDelete("{id:guid}")]
[RequirePermission(ModuleType.Assignments, PermissionAction.Delete)]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var deleted = await _assignmentService.DeleteAsync(id, cancellationToken);
return deleted ? NoContent() : NotFound();
}
private static AssignmentResponse ToResponse(Assignment assignment, IReadOnlyList<AssignmentConflict>? conflicts = null)
=> new(
assignment.Id,
assignment.OrderId,
assignment.EmployeeId,
assignment.Employee.FirstName,
assignment.Employee.LastName,
assignment.StartDate,
assignment.EndDate,
assignment.Note,
(conflicts ?? Array.Empty<AssignmentConflict>()).Select(ToConflictResponse).ToList());
private static AssignmentConflictResponse ToConflictResponse(AssignmentConflict conflict)
=> new(conflict.Type.ToString(), conflict.Severity.ToString(), conflict.Message);
}
@@ -29,6 +29,7 @@ public class TrashController : ControllerBase
private readonly IEmployeeFacilityDistanceService _employeeFacilityDistanceService;
private readonly IAbsenceService _absenceService;
private readonly ITimeEntryService _timeEntryService;
private readonly IAssignmentService _assignmentService;
public TrashController(
IEmployeeService employeeService,
@@ -39,7 +40,8 @@ public class TrashController : ControllerBase
IFacilityQualificationRateService facilityQualificationRateService,
IEmployeeFacilityDistanceService employeeFacilityDistanceService,
IAbsenceService absenceService,
ITimeEntryService timeEntryService)
ITimeEntryService timeEntryService,
IAssignmentService assignmentService)
{
_employeeService = employeeService;
_facilityService = facilityService;
@@ -50,6 +52,7 @@ public class TrashController : ControllerBase
_employeeFacilityDistanceService = employeeFacilityDistanceService;
_absenceService = absenceService;
_timeEntryService = timeEntryService;
_assignmentService = assignmentService;
}
[HttpGet("employees")]
@@ -195,4 +198,20 @@ public class TrashController : ControllerBase
var restored = await _timeEntryService.RestoreAsync(id, cancellationToken);
return restored ? NoContent() : NotFound();
}
[HttpGet("assignments")]
[RequirePermission(ModuleType.Assignments, PermissionAction.Recover)]
public async Task<ActionResult<IReadOnlyList<TrashAssignmentResponse>>> GetDeletedAssignments([FromQuery] string? search, CancellationToken cancellationToken)
{
var assignments = await _assignmentService.GetDeletedAsync(search, cancellationToken);
return Ok(assignments.Select(a => new TrashAssignmentResponse(a.Id, a.Employee.FirstName, a.Employee.LastName, a.DeletedAt)).ToList());
}
[HttpPost("assignments/{id:guid}/restore")]
[RequirePermission(ModuleType.Assignments, PermissionAction.Recover)]
public async Task<IActionResult> RestoreAssignment(Guid id, CancellationToken cancellationToken)
{
var restored = await _assignmentService.RestoreAsync(id, cancellationToken);
return restored ? NoContent() : NotFound();
}
}
@@ -13,6 +13,18 @@ public interface IAbsenceRepository
int pageSize,
CancellationToken cancellationToken = default,
Guid? restrictToEmployeeId = null);
/// <summary>
/// Prüft, ob eine nicht gelöschte Abwesenheit der Mitarbeiterin mit einem Status aus
/// <paramref name="blockingStatusValues"/> (siehe ValueListItem.BlocksAssignment) den Zeitraum
/// [startDate, endDate] überlappt - Basis der Abwesenheitsprüfung in FR-EM-3.
/// </summary>
Task<bool> HasOverlappingAbsenceAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
IReadOnlyCollection<string> blockingStatusValues,
CancellationToken cancellationToken = default);
Task AddAsync(Absence absence, CancellationToken cancellationToken = default);
Task UpdateAsync(Absence absence, CancellationToken cancellationToken = default);
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
@@ -0,0 +1,45 @@
namespace OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
public interface IAssignmentRepository
{
Task<Assignment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Assignment>> GetAllAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Assignment> Items, int TotalCount)> GetPagedAsync(
Guid? orderId = null,
Guid? employeeId = null,
DateOnly? fromDate = null,
DateOnly? toDate = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
Task<bool> HasOverlapAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Zählt die Tage, an denen die Mitarbeiterin innerhalb von [weekStart, weekEnd] bereits
/// eingeteilt ist (bestehende Zuweisungen, ohne excludeAssignmentId) - Basis für die
/// Arbeitszeit-Näherung in FR-EM-3 (8h/Tag gegen Contract.WeeklyHours).
/// </summary>
Task<int> GetAssignedDayCountInWeekAsync(
Guid employeeId,
DateOnly weekStart,
DateOnly weekEnd,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default);
Task<int> CountForOrderAsync(Guid orderId, CancellationToken cancellationToken = default);
Task AddAsync(Assignment assignment, CancellationToken cancellationToken = default);
Task UpdateAsync(Assignment assignment, CancellationToken cancellationToken = default);
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Assignment>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,11 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IAssignmentValidationSettingsRepository
{
/// <summary>Liefert die eine Settings-Zeile, legt sie mit den Entity-Defaults an, falls noch keine existiert.</summary>
Task<AssignmentValidationSettings> GetOrCreateAsync(CancellationToken cancellationToken = default);
Task UpdateAsync(AssignmentValidationSettings settings, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -14,6 +14,17 @@ public interface IContractRepository
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// Liefert den ersten aktiven Vertrag (Status != "Entwurf") der Mitarbeiterin, dessen Zeitraum
/// [startDate, endDate] vollständig abdeckt, oder null - Basis der Vertrags- und der
/// Arbeitszeit-Prüfung (WeeklyHours) in FR-EM-3.
/// </summary>
Task<Contract?> GetActiveForEmployeeCoveringRangeAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
@@ -17,7 +17,9 @@ public static class DependencyInjection
services.AddScoped<IEmployeeFacilityDistanceService, EmployeeFacilityDistanceService>();
services.AddScoped<IContractService, ContractService>();
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IAssignmentService, AssignmentService>();
services.AddScoped<IAbsenceService, AbsenceService>();
services.AddScoped<IAssignmentValidationSettingsService, AssignmentValidationSettingsService>();
services.AddScoped<ITimeEntryService, TimeEntryService>();
services.AddScoped<IDocumentService, DocumentService>();
services.AddScoped<IValueListService, ValueListService>();
@@ -0,0 +1,7 @@
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Services;
public enum AssignmentConflictType { Qualification, Absence, WorkingHours, Overlap, Contract }
public record AssignmentConflict(AssignmentConflictType Type, ValidationSeverity Severity, string Message);
@@ -0,0 +1,290 @@
namespace OmsorgCore.Application.Services;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Enums;
public class AssignmentService : IAssignmentService
{
private const int AssumedHoursPerDay = 8;
private readonly IAssignmentRepository _assignmentRepository;
private readonly IOrderRepository _orderRepository;
private readonly IEmployeeRepository _employeeRepository;
private readonly IAbsenceRepository _absenceRepository;
private readonly IContractRepository _contractRepository;
private readonly IValueListRepository _valueListRepository;
private readonly IAssignmentValidationSettingsRepository _validationSettingsRepository;
public AssignmentService(
IAssignmentRepository assignmentRepository,
IOrderRepository orderRepository,
IEmployeeRepository employeeRepository,
IAbsenceRepository absenceRepository,
IContractRepository contractRepository,
IValueListRepository valueListRepository,
IAssignmentValidationSettingsRepository validationSettingsRepository)
{
_assignmentRepository = assignmentRepository;
_orderRepository = orderRepository;
_employeeRepository = employeeRepository;
_absenceRepository = absenceRepository;
_contractRepository = contractRepository;
_valueListRepository = valueListRepository;
_validationSettingsRepository = validationSettingsRepository;
}
public async Task<(IReadOnlyList<Assignment> Items, int TotalCount)> GetPagedAsync(
Guid? orderId = null,
Guid? employeeId = null,
DateOnly? fromDate = null,
DateOnly? toDate = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
return await _assignmentRepository.GetPagedAsync(
orderId, employeeId, fromDate, toDate, page, pageSize, cancellationToken);
}
public async Task<Assignment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _assignmentRepository.GetByIdAsync(id, cancellationToken);
}
public async Task<CreateAssignmentResult> CreateAsync(
Guid orderId,
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
string? note,
CancellationToken cancellationToken = default)
{
// Validate Order exists
var order = await _orderRepository.GetByIdAsync(orderId, cancellationToken);
if (order is null)
{
return new CreateAssignmentResult(false, CreateAssignmentFailureReason.OrderNotFound, null, Array.Empty<AssignmentConflict>());
}
// Validate Employee exists
var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken);
if (employee is null)
{
return new CreateAssignmentResult(false, CreateAssignmentFailureReason.EmployeeNotFound, null, Array.Empty<AssignmentConflict>());
}
// Validate date range
if (endDate < startDate)
{
return new CreateAssignmentResult(false, CreateAssignmentFailureReason.InvalidDateRange, null, Array.Empty<AssignmentConflict>());
}
// Validate dates are within Order's range
var orderEndDate = order.EndDate ?? DateOnly.MaxValue;
if (startDate < order.StartDate || endDate > orderEndDate)
{
return new CreateAssignmentResult(false, CreateAssignmentFailureReason.DateOutsideOrderRange, null, Array.Empty<AssignmentConflict>());
}
var conflicts = await CheckConflictsAsync(order, employee, startDate, endDate, excludeAssignmentId: null, cancellationToken);
if (conflicts.Any(c => c.Severity == ValidationSeverity.Error))
{
return new CreateAssignmentResult(false, CreateAssignmentFailureReason.ValidationConflict, null, conflicts);
}
// Create assignment
var assignment = new Assignment
{
OrderId = orderId,
EmployeeId = employeeId,
StartDate = startDate,
EndDate = endDate,
Note = note
};
await _assignmentRepository.AddAsync(assignment, cancellationToken);
await _assignmentRepository.SaveChangesAsync(cancellationToken);
// Reload to include the Employee navigation for the result
var reloaded = await _assignmentRepository.GetByIdAsync(assignment.Id, cancellationToken);
return new CreateAssignmentResult(true, CreateAssignmentFailureReason.None, reloaded, conflicts);
}
public async Task<IReadOnlyList<AssignmentConflict>> CheckConflictsAsync(
Guid orderId,
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default)
{
if (endDate < startDate)
{
return Array.Empty<AssignmentConflict>();
}
var order = await _orderRepository.GetByIdAsync(orderId, cancellationToken);
var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken);
if (order is null || employee is null)
{
return Array.Empty<AssignmentConflict>();
}
return await CheckConflictsAsync(order, employee, startDate, endDate, excludeAssignmentId, cancellationToken);
}
private async Task<IReadOnlyList<AssignmentConflict>> CheckConflictsAsync(
Order order,
Employee employee,
DateOnly startDate,
DateOnly endDate,
Guid? excludeAssignmentId,
CancellationToken cancellationToken)
{
var settings = await _validationSettingsRepository.GetOrCreateAsync(cancellationToken);
var conflicts = new List<AssignmentConflict>();
// Qualifikation: Employee.Qualification muss mindestens den Rang von Order.RequiredQualification haben
// (SortOrder der gemeinsamen ValueList "Qualification", siehe omsorgCore/CLAUDE.md "Konfigurierbare Auswahllisten").
if (!string.IsNullOrWhiteSpace(order.RequiredQualification))
{
var qualificationItems = await _valueListRepository.GetItemsAsync("Qualification", cancellationToken);
var requiredItem = qualificationItems.FirstOrDefault(i => i.Value == order.RequiredQualification);
var employeeItem = qualificationItems.FirstOrDefault(i => i.Value == employee.Qualification);
if (requiredItem is not null && (employeeItem is null || employeeItem.SortOrder < requiredItem.SortOrder))
{
conflicts.Add(new AssignmentConflict(
AssignmentConflictType.Qualification,
settings.QualificationMode,
$"Mitarbeiterin erfüllt die benötigte Qualifikation \"{order.RequiredQualification}\" nicht (aktuell: \"{employee.Qualification ?? "keine"}\")."));
}
}
// Abwesenheit (deckt "Verfügbarkeit" ab, siehe Plan): genehmigte Abwesenheit überlappt Zeitraum.
var blockingAbsenceStatuses = (await _valueListRepository.GetItemsAsync("AbsenceStatus", cancellationToken))
.Where(i => i.BlocksAssignment)
.Select(i => i.Value)
.ToList();
if (await _absenceRepository.HasOverlappingAbsenceAsync(employee.Id, startDate, endDate, blockingAbsenceStatuses, cancellationToken))
{
conflicts.Add(new AssignmentConflict(
AssignmentConflictType.Absence,
settings.AbsenceMode,
"Mitarbeiterin hat im Zuweisungszeitraum eine genehmigte Abwesenheit."));
}
// Überschneidung: jetzt auftragsübergreifend geprüft (nicht mehr nur derselbe Auftrag).
if (await _assignmentRepository.HasOverlapAsync(employee.Id, startDate, endDate, excludeAssignmentId, cancellationToken))
{
conflicts.Add(new AssignmentConflict(
AssignmentConflictType.Overlap,
settings.OverlapMode,
"Mitarbeiterin ist im Zuweisungszeitraum bereits einem anderen Auftrag zugewiesen."));
}
// Vertrag + Arbeitszeit: beide brauchen den aktiven Vertrag der Mitarbeiterin für den Zeitraum.
var activeContract = await _contractRepository.GetActiveForEmployeeCoveringRangeAsync(employee.Id, startDate, endDate, cancellationToken);
if (activeContract is null)
{
conflicts.Add(new AssignmentConflict(
AssignmentConflictType.Contract,
settings.ContractMode,
"Mitarbeiterin hat keinen aktiven Vertrag, der den Zuweisungszeitraum abdeckt."));
}
else if (activeContract.WeeklyHours.HasValue)
{
var weeklyHourCap = activeContract.WeeklyHours.Value;
var maxDaysPerWeek = (int)Math.Ceiling(weeklyHourCap / AssumedHoursPerDay);
var current = startDate;
while (current <= endDate)
{
var weekStart = GetIsoWeekStart(current);
var weekEnd = weekStart.AddDays(6);
var existingDays = await _assignmentRepository.GetAssignedDayCountInWeekAsync(
employee.Id, weekStart, weekEnd, excludeAssignmentId, cancellationToken);
var newDaysInWeek = CountOverlapDays(startDate, endDate, weekStart, weekEnd);
var totalDays = existingDays + newDaysInWeek;
if (totalDays > maxDaysPerWeek)
{
conflicts.Add(new AssignmentConflict(
AssignmentConflictType.WorkingHours,
settings.WorkingHoursMode,
$"Arbeitszeit-Näherung (8h/Tag) überschreitet in der Woche ab {weekStart:yyyy-MM-dd} die Vertrags-Wochenstunden ({weeklyHourCap}h): {totalDays} zugewiesene Tage statt max. {maxDaysPerWeek}."));
break;
}
current = weekEnd.AddDays(1);
}
}
return conflicts;
}
private static DateOnly GetIsoWeekStart(DateOnly date)
{
var daysSinceMonday = ((int)date.DayOfWeek + 6) % 7;
return date.AddDays(-daysSinceMonday);
}
private static int CountOverlapDays(DateOnly aStart, DateOnly aEnd, DateOnly bStart, DateOnly bEnd)
{
var rangeStart = aStart > bStart ? aStart : bStart;
var rangeEnd = aEnd < bEnd ? aEnd : bEnd;
return rangeEnd < rangeStart ? 0 : rangeEnd.DayNumber - rangeStart.DayNumber + 1;
}
public async Task<Assignment?> UpdateAsync(
Guid id,
string? note,
CancellationToken cancellationToken = default)
{
var assignment = await _assignmentRepository.GetByIdAsync(id, cancellationToken);
if (assignment is null)
{
return null;
}
assignment.Note = note;
await _assignmentRepository.UpdateAsync(assignment, cancellationToken);
await _assignmentRepository.SaveChangesAsync(cancellationToken);
return assignment;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var deleted = await _assignmentRepository.SoftDeleteAsync(id, cancellationToken);
if (deleted)
{
await _assignmentRepository.SaveChangesAsync(cancellationToken);
}
return deleted;
}
public async Task<IReadOnlyList<Assignment>> GetDeletedAsync(string? search = null, CancellationToken cancellationToken = default)
{
return await _assignmentRepository.GetDeletedAsync(search, cancellationToken);
}
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
{
var restored = await _assignmentRepository.RestoreAsync(id, cancellationToken);
if (restored)
{
await _assignmentRepository.SaveChangesAsync(cancellationToken);
}
return restored;
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> _assignmentRepository.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,39 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Services;
public class AssignmentValidationSettingsService : IAssignmentValidationSettingsService
{
private readonly IAssignmentValidationSettingsRepository _repository;
public AssignmentValidationSettingsService(IAssignmentValidationSettingsRepository repository)
{
_repository = repository;
}
public Task<AssignmentValidationSettings> GetAsync(CancellationToken cancellationToken = default)
=> _repository.GetOrCreateAsync(cancellationToken);
public async Task<AssignmentValidationSettings> UpdateAsync(
ValidationSeverity qualificationMode,
ValidationSeverity absenceMode,
ValidationSeverity workingHoursMode,
ValidationSeverity overlapMode,
ValidationSeverity contractMode,
CancellationToken cancellationToken = default)
{
var settings = await _repository.GetOrCreateAsync(cancellationToken);
settings.QualificationMode = qualificationMode;
settings.AbsenceMode = absenceMode;
settings.WorkingHoursMode = workingHoursMode;
settings.OverlapMode = overlapMode;
settings.ContractMode = contractMode;
await _repository.UpdateAsync(settings, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return settings;
}
}
@@ -0,0 +1,11 @@
namespace OmsorgCore.Application.Services;
using OmsorgCore.Domain.Entities;
public enum CreateAssignmentFailureReason { None, OrderNotFound, EmployeeNotFound, InvalidDateRange, DateOutsideOrderRange, OverlappingAssignment, ValidationConflict }
public record CreateAssignmentResult(
bool Success,
CreateAssignmentFailureReason Reason,
Assignment? Assignment,
IReadOnlyList<AssignmentConflict> Conflicts);
@@ -0,0 +1,43 @@
namespace OmsorgCore.Application.Services;
using OmsorgCore.Domain.Entities;
public interface IAssignmentService
{
Task<(IReadOnlyList<Assignment> Items, int TotalCount)> GetPagedAsync(
Guid? orderId = null,
Guid? employeeId = null,
DateOnly? fromDate = null,
DateOnly? toDate = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
Task<Assignment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<CreateAssignmentResult> CreateAsync(Guid orderId, Guid employeeId, DateOnly startDate, DateOnly endDate, string? note, CancellationToken cancellationToken = default);
/// <summary>
/// Führt dieselben FR-EM-3-Konfliktprüfungen wie <see cref="CreateAsync"/> aus, ohne eine
/// Zuweisung anzulegen - für die Live-Vorschau im Anlegen-/Bearbeiten-Dialog. Liefert eine
/// leere Liste, wenn Auftrag/Mitarbeiter (noch) nicht existieren oder der Zeitraum ungültig ist
/// (diese Fälle werden im Formular bereits über Pflichtfelder/Min-Max abgefangen).
/// </summary>
Task<IReadOnlyList<AssignmentConflict>> CheckConflictsAsync(
Guid orderId,
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default);
Task<Assignment?> UpdateAsync(Guid id, string? note, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Assignment>> GetDeletedAsync(string? search = null, CancellationToken cancellationToken = default);
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,17 @@
using OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Services;
public interface IAssignmentValidationSettingsService
{
Task<AssignmentValidationSettings> GetAsync(CancellationToken cancellationToken = default);
Task<AssignmentValidationSettings> UpdateAsync(
ValidationSeverity qualificationMode,
ValidationSeverity absenceMode,
ValidationSeverity workingHoursMode,
ValidationSeverity overlapMode,
ValidationSeverity contractMode,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
namespace OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Common;
public class Assignment : AuditableEntity
{
public Guid OrderId { get; set; }
public Order Order { get; set; } = null!;
public Guid EmployeeId { get; set; }
public Employee Employee { get; set; } = null!;
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public string? Note { get; set; }
}
@@ -0,0 +1,19 @@
using OmsorgCore.Domain.Common;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Systemweite, zur Laufzeit änderbare Konfiguration der FR-EM-3-Konfliktprüfungen bei der
/// Mitarbeiterzuweisung (<see cref="Assignment"/>). Genau eine Zeile (Singleton) —
/// <see cref="Application.Abstractions.IAssignmentValidationSettingsRepository.GetOrCreateAsync"/>
/// legt sie bei Bedarf mit den hier hinterlegten Defaults an.
/// </summary>
public class AssignmentValidationSettings : Entity
{
public ValidationSeverity QualificationMode { get; set; } = ValidationSeverity.Error;
public ValidationSeverity AbsenceMode { get; set; } = ValidationSeverity.Error;
public ValidationSeverity WorkingHoursMode { get; set; } = ValidationSeverity.Warning;
public ValidationSeverity OverlapMode { get; set; } = ValidationSeverity.Error;
public ValidationSeverity ContractMode { get; set; } = ValidationSeverity.Error;
}
@@ -23,4 +23,11 @@ public class ValueListItem : Entity
public bool IsTerminal { get; set; }
public bool TriggersFollowUp { get; set; }
public bool IsEditableByOwner { get; set; }
/// <summary>
/// Nur für "AbsenceStatus" relevant: markiert den Status-Wert (aktuell "Genehmigt"), der eine
/// Mitarbeiterzuweisung im überlappenden Zeitraum blockiert/warnt (FR-EM-3, Abwesenheitsprüfung).
/// Bei allen anderen Listen bleibt es <c>false</c> — analog zu <see cref="IsInitial"/>/<see cref="IsTerminal"/>.
/// </summary>
public bool BlocksAssignment { get; set; }
}
@@ -19,5 +19,6 @@ public enum ModuleType
Users,
Configuration,
Absences,
EmployeeFacilityDistances
EmployeeFacilityDistances,
Assignments
}
@@ -0,0 +1,11 @@
namespace OmsorgCore.Domain.Enums;
/// <summary>
/// Steuert, ob eine Zuweisungs-Konfliktprüfung (FR-EM-3) die Zuweisung blockiert (<see cref="Error"/>)
/// oder nur als Hinweis mitgegeben wird, ohne sie zu verhindern (<see cref="Warning"/>).
/// </summary>
public enum ValidationSeverity
{
Warning,
Error
}
@@ -38,7 +38,9 @@ public static class DependencyInjection
services.AddScoped<IEmployeeFacilityDistanceRepository, EmployeeFacilityDistanceRepository>();
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IAssignmentRepository, AssignmentRepository>();
services.AddScoped<IAbsenceRepository, AbsenceRepository>();
services.AddScoped<IAssignmentValidationSettingsRepository, AssignmentValidationSettingsRepository>();
services.AddScoped<ITimeEntryRepository, TimeEntryRepository>();
services.AddScoped<IDocumentRepository, DocumentRepository>();
services.AddScoped<IDocumentStorage, FileSystemDocumentStorage>();
@@ -0,0 +1,18 @@
namespace OmsorgCore.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using OmsorgCore.Domain.Entities;
public class AssignmentConfiguration : IEntityTypeConfiguration<Assignment>
{
public void Configure(EntityTypeBuilder<Assignment> builder)
{
builder.ToTable("assignments");
builder.HasKey(a => a.Id);
builder.HasOne(a => a.Order).WithMany().HasForeignKey(a => a.OrderId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Employee).WithMany().HasForeignKey(a => a.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(a => a.Note).HasMaxLength(500);
builder.HasQueryFilter(a => !a.IsDeleted);
}
}
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Infrastructure.Persistence.Configurations;
public class AssignmentValidationSettingsConfiguration : IEntityTypeConfiguration<AssignmentValidationSettings>
{
public void Configure(EntityTypeBuilder<AssignmentValidationSettings> builder)
{
builder.ToTable("assignment_validation_settings");
builder.HasKey(s => s.Id);
}
}
@@ -88,6 +88,7 @@ public static class DbSeeder
(ModuleType.Facilities, AllActions, PermissionScope.All),
(ModuleType.Contracts, AllActions, PermissionScope.All),
(ModuleType.Orders, AllActions, PermissionScope.All),
(ModuleType.Assignments, AllActions, PermissionScope.All),
(ModuleType.TimeEntries, AllActions, PermissionScope.All),
(ModuleType.Invoices, new[] { PermissionAction.View, PermissionAction.Create, PermissionAction.Approve }, PermissionScope.All),
(ModuleType.Recruiting, new[] { PermissionAction.View, PermissionAction.Create, PermissionAction.Edit }, PermissionScope.All),
@@ -107,6 +108,7 @@ public static class DbSeeder
{
(ModuleType.Employees, new[] { PermissionAction.View }, PermissionScope.Own),
(ModuleType.Contracts, new[] { PermissionAction.View }, PermissionScope.Own),
(ModuleType.Assignments, 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.EmployeeFacilityDistances, new[] { PermissionAction.Create, PermissionAction.View, PermissionAction.Edit }, PermissionScope.Own)
@@ -204,6 +206,7 @@ public static class DbSeeder
await SeedSimpleListIfMissingAsync(db, "AbsenceStatus", "Antragsstatus",
new (string Value, bool IsDefault)[] { ("Eingereicht", true), ("Genehmigt", false), ("Abgelehnt", false) }, cancellationToken);
await SeedAbsenceStatusInitialFlagIfMissingAsync(db, cancellationToken);
await SeedAbsenceStatusBlocksAssignmentFlagIfMissingAsync(db, cancellationToken);
await SeedOrderStatusListIfMissingAsync(db, cancellationToken);
await SeedCrmStatusTransitionsIfMissingAsync(db, cancellationToken);
@@ -439,6 +442,29 @@ public static class DbSeeder
item.IsInitial = true;
await context.SaveChangesAsync(ct);
}
// "Genehmigt" ist der einzige AbsenceStatus-Wert, der eine Mitarbeiterzuweisung im
// überlappenden Zeitraum blockiert/warnt (FR-EM-3, AssignmentService.CreateAsync liest
// darüber statt den Anzeigetext hartzukodieren, siehe BlocksAssignment auf ValueListItem).
// Idempotent wie SeedAbsenceStatusInitialFlagIfMissingAsync - läuft nicht erneut, sobald
// irgendein Item der Liste bereits BlocksAssignment trägt.
async Task SeedAbsenceStatusBlocksAssignmentFlagIfMissingAsync(OmsorgCoreDbContext context, CancellationToken ct)
{
if (await context.ValueListItems.AnyAsync(i => i.ValueList.Key == "AbsenceStatus" && i.BlocksAssignment, ct))
{
return;
}
var item = await context.ValueListItems
.FirstOrDefaultAsync(i => i.ValueList.Key == "AbsenceStatus" && i.Value == "Genehmigt", ct);
if (item is null)
{
return;
}
item.BlocksAssignment = true;
await context.SaveChangesAsync(ct);
}
}
private static async Task SeedRoleIfMissingAsync(
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OmsorgCore.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAssignmentEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "assignments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
EmployeeId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
Note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
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_assignments", x => x.Id);
table.ForeignKey(
name: "FK_assignments_employees_EmployeeId",
column: x => x.EmployeeId,
principalTable: "employees",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_assignments_orders_OrderId",
column: x => x.OrderId,
principalTable: "orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_assignments_EmployeeId",
table: "assignments",
column: "EmployeeId");
migrationBuilder.CreateIndex(
name: "IX_assignments_OrderId",
table: "assignments",
column: "OrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "assignments");
}
}
}
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OmsorgCore.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAssignmentValidationSettingsAndBlocksAssignmentFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BlocksAssignment",
table: "value_list_items",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "assignment_validation_settings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
QualificationMode = table.Column<int>(type: "integer", nullable: false),
AbsenceMode = table.Column<int>(type: "integer", nullable: false),
WorkingHoursMode = table.Column<int>(type: "integer", nullable: false),
OverlapMode = table.Column<int>(type: "integer", nullable: false),
ContractMode = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_assignment_validation_settings", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "assignment_validation_settings");
migrationBuilder.DropColumn(
name: "BlocksAssignment",
table: "value_list_items");
}
}
}
@@ -82,6 +82,75 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
b.ToTable("absences", (string)null);
});
modelBuilder.Entity("OmsorgCore.Domain.Entities.Assignment", 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<Guid>("EmployeeId")
.HasColumnType("uuid");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("OrderId")
.HasColumnType("uuid");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("EmployeeId");
b.HasIndex("OrderId");
b.ToTable("assignments", (string)null);
});
modelBuilder.Entity("OmsorgCore.Domain.Entities.AssignmentValidationSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AbsenceMode")
.HasColumnType("integer");
b.Property<int>("ContractMode")
.HasColumnType("integer");
b.Property<int>("OverlapMode")
.HasColumnType("integer");
b.Property<int>("QualificationMode")
.HasColumnType("integer");
b.Property<int>("WorkingHoursMode")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("assignment_validation_settings", (string)null);
});
modelBuilder.Entity("OmsorgCore.Domain.Entities.AuditLogEntry", b =>
{
b.Property<Guid>("Id")
@@ -1064,6 +1133,9 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("BlocksAssignment")
.HasColumnType("boolean");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
@@ -1136,6 +1208,25 @@ namespace OmsorgCore.Infrastructure.Persistence.Migrations
b.Navigation("Employee");
});
modelBuilder.Entity("OmsorgCore.Domain.Entities.Assignment", b =>
{
b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee")
.WithMany()
.HasForeignKey("EmployeeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("OmsorgCore.Domain.Entities.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Employee");
b.Navigation("Order");
});
modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b =>
{
b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee")
@@ -16,7 +16,9 @@ public class OmsorgCoreDbContext : DbContext
public DbSet<EmployeeFacilityDistance> EmployeeFacilityDistances => Set<EmployeeFacilityDistance>();
public DbSet<Contract> Contracts => Set<Contract>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Assignment> Assignments => Set<Assignment>();
public DbSet<Absence> Absences => Set<Absence>();
public DbSet<AssignmentValidationSettings> AssignmentValidationSettings => Set<AssignmentValidationSettings>();
public DbSet<ValueList> ValueLists => Set<ValueList>();
public DbSet<ValueListItem> ValueListItems => Set<ValueListItem>();
public DbSet<ValueListItemTransition> ValueListItemTransitions => Set<ValueListItemTransition>();
@@ -58,6 +58,25 @@ public class AbsenceRepository : IAbsenceRepository
return (items, totalCount);
}
public async Task<bool> HasOverlappingAbsenceAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
IReadOnlyCollection<string> blockingStatusValues,
CancellationToken cancellationToken = default)
{
if (blockingStatusValues.Count == 0)
{
return false;
}
return await _db.Absences
.Where(a => a.EmployeeId == employeeId && !a.IsDeleted)
.Where(a => blockingStatusValues.Contains(a.Status))
.Where(a => a.StartDate <= endDate && a.EndDate >= startDate)
.AnyAsync(cancellationToken);
}
public async Task AddAsync(Absence absence, CancellationToken cancellationToken = default)
=> await _db.Absences.AddAsync(absence, cancellationToken);
@@ -0,0 +1,164 @@
using Microsoft.EntityFrameworkCore;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Infrastructure.Persistence;
namespace OmsorgCore.Infrastructure.Repositories;
public class AssignmentRepository : IAssignmentRepository
{
private readonly OmsorgCoreDbContext _db;
public AssignmentRepository(OmsorgCoreDbContext db)
{
_db = db;
}
public Task<Assignment?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> _db.Assignments.Include(a => a.Employee).FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
public async Task<IReadOnlyList<Assignment>> GetAllAsync(CancellationToken cancellationToken = default)
=> await _db.Assignments.AsNoTracking().Include(a => a.Employee).ToListAsync(cancellationToken);
public async Task<(IReadOnlyList<Assignment> Items, int TotalCount)> GetPagedAsync(
Guid? orderId = null,
Guid? employeeId = null,
DateOnly? fromDate = null,
DateOnly? toDate = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = _db.Assignments.AsNoTracking().Include(a => a.Employee).Where(a => !a.IsDeleted);
if (orderId.HasValue)
{
query = query.Where(a => a.OrderId == orderId.Value);
}
if (employeeId.HasValue)
{
query = query.Where(a => a.EmployeeId == employeeId.Value);
}
if (fromDate.HasValue && toDate.HasValue)
{
query = query.Where(a => a.StartDate <= toDate.Value && a.EndDate >= fromDate.Value);
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(a => a.StartDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
public async Task<bool> HasOverlapAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default)
{
var query = _db.Assignments
.Where(a => a.EmployeeId == employeeId && !a.IsDeleted)
.Where(a => a.StartDate <= endDate && a.EndDate >= startDate);
if (excludeAssignmentId.HasValue)
{
query = query.Where(a => a.Id != excludeAssignmentId.Value);
}
return await query.AnyAsync(cancellationToken);
}
public async Task<int> GetAssignedDayCountInWeekAsync(
Guid employeeId,
DateOnly weekStart,
DateOnly weekEnd,
Guid? excludeAssignmentId = null,
CancellationToken cancellationToken = default)
{
var query = _db.Assignments
.Where(a => a.EmployeeId == employeeId && !a.IsDeleted)
.Where(a => a.StartDate <= weekEnd && a.EndDate >= weekStart);
if (excludeAssignmentId.HasValue)
{
query = query.Where(a => a.Id != excludeAssignmentId.Value);
}
var overlapping = await query
.Select(a => new { a.StartDate, a.EndDate })
.ToListAsync(cancellationToken);
var days = 0;
foreach (var a in overlapping)
{
var rangeStart = a.StartDate > weekStart ? a.StartDate : weekStart;
var rangeEnd = a.EndDate < weekEnd ? a.EndDate : weekEnd;
days += rangeEnd.DayNumber - rangeStart.DayNumber + 1;
}
return days;
}
public async Task<int> CountForOrderAsync(Guid orderId, CancellationToken cancellationToken = default)
=> await _db.Assignments.CountAsync(a => a.OrderId == orderId && !a.IsDeleted, cancellationToken);
public async Task AddAsync(Assignment assignment, CancellationToken cancellationToken = default)
=> await _db.Assignments.AddAsync(assignment, cancellationToken);
public Task UpdateAsync(Assignment assignment, CancellationToken cancellationToken = default)
{
_db.Assignments.Update(assignment);
return Task.CompletedTask;
}
public async Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var assignment = await _db.Assignments.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (assignment is null)
{
return false;
}
assignment.IsDeleted = true;
assignment.DeletedAt = DateTime.UtcNow;
return true;
}
public async Task<IReadOnlyList<Assignment>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
{
var query = _db.Assignments.IgnoreQueryFilters().AsNoTracking().Include(a => a.Employee).Where(a => a.IsDeleted);
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(a =>
EF.Functions.ILike(a.Employee.FirstName, $"%{search}%") ||
EF.Functions.ILike(a.Employee.LastName, $"%{search}%"));
}
return await query.OrderByDescending(a => a.DeletedAt).ToListAsync(cancellationToken);
}
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
{
var assignment = await _db.Assignments.IgnoreQueryFilters().FirstOrDefaultAsync(a => a.Id == id && a.IsDeleted, cancellationToken);
if (assignment is null)
{
return false;
}
assignment.IsDeleted = false;
assignment.DeletedAt = null;
return true;
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> _db.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Infrastructure.Persistence;
namespace OmsorgCore.Infrastructure.Repositories;
public class AssignmentValidationSettingsRepository : IAssignmentValidationSettingsRepository
{
private readonly OmsorgCoreDbContext _db;
public AssignmentValidationSettingsRepository(OmsorgCoreDbContext db)
{
_db = db;
}
public async Task<AssignmentValidationSettings> GetOrCreateAsync(CancellationToken cancellationToken = default)
{
var settings = await _db.AssignmentValidationSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is not null)
{
return settings;
}
settings = new AssignmentValidationSettings();
await _db.AssignmentValidationSettings.AddAsync(settings, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
return settings;
}
public Task UpdateAsync(AssignmentValidationSettings settings, CancellationToken cancellationToken = default)
{
_db.AssignmentValidationSettings.Update(settings);
return Task.CompletedTask;
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> _db.SaveChangesAsync(cancellationToken);
}
@@ -63,6 +63,16 @@ public class ContractRepository : IContractRepository
return (items, totalCount);
}
public Task<Contract?> GetActiveForEmployeeCoveringRangeAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
=> _db.Contracts
.Where(c => !c.IsDeleted && c.EmployeeId == employeeId && c.Status != "Entwurf")
.Where(c => c.StartDate <= startDate && (c.EndDate == null || c.EndDate >= endDate))
.FirstOrDefaultAsync(cancellationToken);
public async Task AddAsync(Contract contract, CancellationToken cancellationToken = default)
=> await _db.Contracts.AddAsync(contract, cancellationToken);
@@ -492,6 +492,15 @@ public class FakeContractRepository : IContractRepository
return Task.FromResult<(IReadOnlyList<Contract> Items, int TotalCount)>((items, totalCount));
}
public Task<Contract?> GetActiveForEmployeeCoveringRangeAsync(
Guid employeeId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
=> Task.FromResult(_contracts.FirstOrDefault(c =>
!c.IsDeleted && c.EmployeeId == employeeId && c.Status != "Entwurf" &&
c.StartDate <= startDate && (c.EndDate == null || c.EndDate >= endDate)));
public Task AddAsync(Contract contract, CancellationToken cancellationToken = default)
{
_contracts.Add(contract);