diff --git a/src/Buy2.Application/Common/Interfaces/IRepository.cs b/src/Buy2.Application/Common/Interfaces/IRepository.cs index 5a33ee0..29776e9 100644 --- a/src/Buy2.Application/Common/Interfaces/IRepository.cs +++ b/src/Buy2.Application/Common/Interfaces/IRepository.cs @@ -1,3 +1,4 @@ +using Buy2.Application.Common.Specifications; using Buy2.Domain.Entities; using System.Linq.Expressions; @@ -13,5 +14,60 @@ public interface IRepository where T : class Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); void Update(T entity); void Delete(T entity); + + // TEMPORARY default implementations for the stacked review of SCRUM-389. + // The specification-based reads are implemented by GenericRepository; + // the follow-up (SCRUM-390) turns these into abstract members and removes Query(). + // Existing IRepository implementers (e.g. test fakes) keep compiling unaffected. + Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(FirstOrDefaultAsync)} is implemented by GenericRepository."); + + Task FirstOrDefaultAsync( + Expression> predicate, + CancellationToken cancellationToken = default, + params string[] includes) => + throw new NotImplementedException($"{nameof(FirstOrDefaultAsync)} is implemented by GenericRepository."); + + Task FirstOrDefaultAsync( + ISpecification specification, + Expression> selector, + CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(FirstOrDefaultAsync)} is implemented by GenericRepository."); + + Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(ListAsync)} is implemented by GenericRepository."); + + Task> ListAsync( + Expression>? predicate = null, + CancellationToken cancellationToken = default, + params string[] includes) => + throw new NotImplementedException($"{nameof(ListAsync)} is implemented by GenericRepository."); + + Task> ListAsync( + ISpecification specification, + Expression> selector, + CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(ListAsync)} is implemented by GenericRepository."); + + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(CountAsync)} is implemented by GenericRepository."); + + Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(CountAsync)} is implemented by GenericRepository."); + + Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(SumAsync)} is implemented by GenericRepository."); + + Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(SumAsync)} is implemented by GenericRepository."); + + Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(SumAsync)} is implemented by GenericRepository."); + + Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(SumAsync)} is implemented by GenericRepository."); + + Task> PagedAsync(ISpecification specification, int pageNumber, int pageSize, CancellationToken cancellationToken = default) => + throw new NotImplementedException($"{nameof(PagedAsync)} is implemented by GenericRepository."); } -} \ No newline at end of file +} diff --git a/src/Buy2.Application/Common/Specifications/Specification.cs b/src/Buy2.Application/Common/Specifications/Specification.cs new file mode 100644 index 0000000..120a6d7 --- /dev/null +++ b/src/Buy2.Application/Common/Specifications/Specification.cs @@ -0,0 +1,129 @@ +using System.Linq.Expressions; + +namespace Buy2.Application.Common.Specifications; + +/// +/// Persistence-agnostic sort instruction. Interpreted by the infrastructure +/// layer (EF Core today); application code never touches IQueryable. +/// +public sealed class Ordering +{ + public Expression> KeySelector { get; } + public bool Descending { get; } + + public Ordering(Expression> keySelector, bool descending = false) + { + KeySelector = keySelector; + Descending = descending; + } +} + +/// +/// Persistence-agnostic query description: filter + eager-load paths + +/// ordering + paging + tracking behavior. Lives in Application so handlers +/// compose queries without referencing any ORM. +/// Include paths use dotted navigation names, e.g. "JobRole.Department". +/// +public interface ISpecification where T : class +{ + Expression>? Criteria { get; } + IReadOnlyList Includes { get; } + IReadOnlyList> Orderings { get; } + int? Skip { get; } + int? Take { get; } + bool Tracked { get; } + bool IgnoreQueryFilters { get; } +} + +public class Specification : ISpecification where T : class +{ + private readonly List>> _wheres = new(); + private readonly List _includes = new(); + private readonly List> _orderings = new(); + + public Expression>? Criteria => + _wheres.Count == 0 ? null : _wheres.Aggregate(ExpressionCombiner.AndAlso); + + public IReadOnlyList Includes => _includes; + public IReadOnlyList> Orderings => _orderings; + public int? Skip { get; private set; } + public int? Take { get; private set; } + public bool Tracked { get; private set; } + public bool IgnoreQueryFilters { get; private set; } + + public Specification Where(Expression> predicate) + { + _wheres.Add(predicate); + return this; + } + + public Specification Include(params string[] paths) + { + foreach (var path in paths) + { + if (!string.IsNullOrWhiteSpace(path) && !_includes.Contains(path)) + { + _includes.Add(path); + } + } + + return this; + } + + public Specification OrderBy(Expression> keySelector, bool descending = false) + { + _orderings.Add(new Ordering(keySelector, descending)); + return this; + } + + public Specification ThenBy(Expression> keySelector, bool descending = false) + { + return OrderBy(keySelector, descending); + } + + public Specification Page(int skip, int take) + { + Skip = skip; + Take = take; + return this; + } + + public Specification AsTracked() + { + Tracked = true; + return this; + } + + public Specification IgnoreFilters() + { + IgnoreQueryFilters = true; + return this; + } +} + +/// +/// Paged query result. TotalCount is computed from the filter +/// without paging; Items contains only the requested page. +/// +public sealed record PagedResult( + IReadOnlyList Items, + int TotalCount, + int PageNumber, + int PageSize) +{ + public int TotalPages => TotalCount == 0 ? 0 : (int)Math.Ceiling((double)TotalCount / PageSize); +} + +internal static class ExpressionCombiner +{ + public static Expression> AndAlso( + Expression> left, + Expression> right) + { + var parameter = Expression.Parameter(typeof(T), "e"); + var body = Expression.AndAlso( + Expression.Invoke(left, parameter), + Expression.Invoke(right, parameter)); + return Expression.Lambda>(body, parameter); + } +} diff --git a/src/Buy2.Application/DependencyInjection.cs b/src/Buy2.Application/DependencyInjection.cs index 0d736fe..ddbdc79 100644 --- a/src/Buy2.Application/DependencyInjection.cs +++ b/src/Buy2.Application/DependencyInjection.cs @@ -1,5 +1,6 @@ using Buy2.Application.Features.Points.Automation; using Buy2.Application.Features.Points.Automation.Evaluators; +using Buy2.Application.Features.Schedules.ApplyTemplate.Services; using Buy2.Application.Features.ShiftTemplates.UpdateShiftTemplate; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -11,12 +12,17 @@ public static class DependencyInjection public static IServiceCollection AddApplicationServices(this IServiceCollection services) { services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); - + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/ApplyTemplateCommandHandler.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/ApplyTemplateCommandHandler.cs index fa79b46..a906ffb 100644 --- a/src/Buy2.Application/Features/Schedules/ApplyTemplate/ApplyTemplateCommandHandler.cs +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/ApplyTemplateCommandHandler.cs @@ -1,27 +1,40 @@ -using Buy2.Application.Common.Helpers; using Buy2.Application.Common.Interfaces; using Buy2.Application.Common.Models; using Buy2.Application.DTOs.Schedules; +using Buy2.Application.Features.Schedules.ApplyTemplate.Services; using Buy2.Domain.Entities; -using Buy2.Domain.Enums; using MediatR; -using Microsoft.EntityFrameworkCore; namespace Buy2.Application.Features.Schedules.ApplyTemplate; public class ApplyTemplateCommandHandler : IRequestHandler> { - private readonly IRepository _siteRepository; - private readonly IRepository _templateRepository; - private readonly IRepository _shiftRepository; - private readonly IRepository _employeeRepository; - private readonly IRepository _jobRoleRepository; - private readonly IRepository _employeeSiteRepository; - private readonly IRepository _operationalHourRepository; - private readonly IRepository _requestRepository; - private readonly IRepository _attendanceRepository; - private readonly IUnitOfWork _unitOfWork; + private readonly ITemplateApplicationLoader _loader; + private readonly IAvailabilityResolver _availability; + private readonly IEligibilityEvaluator _eligibility; + private readonly IOverlapResolver _overlaps; + private readonly IScheduleAnalyticsService _analytics; + public ApplyTemplateCommandHandler( + ITemplateApplicationLoader loader, + IAvailabilityResolver availability, + IEligibilityEvaluator eligibility, + IOverlapResolver overlaps, + IScheduleAnalyticsService analytics) + { + _loader = loader; + _availability = availability; + _eligibility = eligibility; + _overlaps = overlaps; + _analytics = analytics; + } + + /// + /// Backward-compatibility ctor for existing call sites / tests that + /// construct the handler with repositories directly. Composes the + /// focused services internally. Prefer the 5-service ctor via DI. + /// + [Obsolete("Use the (ITemplateApplicationLoader, IAvailabilityResolver, IEligibilityEvaluator, IOverlapResolver, IScheduleAnalyticsService) ctor via DI.")] public ApplyTemplateCommandHandler( IRepository siteRepository, IRepository templateRepository, @@ -33,74 +46,36 @@ public ApplyTemplateCommandHandler( IRepository requestRepository, IRepository attendanceRepository, IUnitOfWork unitOfWork) + : this( + new TemplateApplicationLoader( + siteRepository, templateRepository, shiftRepository, + employeeRepository, jobRoleRepository, unitOfWork), + new AvailabilityResolver( + employeeSiteRepository, operationalHourRepository, + requestRepository, attendanceRepository), + new EligibilityEvaluator(), + new OverlapResolver(shiftRepository), + new ScheduleAnalyticsService()) { - _siteRepository = siteRepository; - _templateRepository = templateRepository; - _shiftRepository = shiftRepository; - _employeeRepository = employeeRepository; - _jobRoleRepository = jobRoleRepository; - _employeeSiteRepository = employeeSiteRepository; - _operationalHourRepository = operationalHourRepository; - _requestRepository = requestRepository; - _attendanceRepository = attendanceRepository; - _unitOfWork = unitOfWork; - } - - private enum KeepMode - { - None, - KeepNew, - KeepExisting - } - - private sealed record StripDecision(string Code, string Reason); - - private sealed class PlannedBlock - { - public ShiftEntity Entity { get; set; } = null!; - public string RoleTitle { get; set; } = string.Empty; - public int? SourceEmployeeId { get; set; } - public string? SourceEmployeeName { get; set; } - public string? AssignedEmployeeName { get; set; } - public bool Stripped { get; set; } - public string? StripCode { get; set; } - public string? StripReason { get; set; } - public bool Collision { get; set; } - public string? CollisionType { get; set; } - public bool Conflict { get; set; } - public string? ConflictType { get; set; } - public bool Pruned { get; set; } - public HashSet OverlappingExistingIds { get; } = new(); - } - - private sealed class AvailabilityContext - { - public HashSet AuthorizedEmployeeIds { get; set; } = new(); - public bool IsDayOff { get; set; } - public SiteOperationalHour? DayHours { get; set; } - public List LeaveRequests { get; set; } = new(); - public HashSet LeaveRecordEmployeeIds { get; set; } = new(); - public Dictionary LeaveTypeByEmployeeId { get; set; } = new(); - public Dictionary RemoteWorkByEmployeeId { get; set; } = new(); } public async Task> Handle( ApplyTemplateCommand request, CancellationToken cancellationToken) { - var keep = ParseKeepMode(request.Keep); + var keep = _overlaps.ParseKeepMode(request.Keep); if (keep == null) { return Result.ValidationFailure( "Invalid keep value. Supported values: new, existing."); } - var site = await LoadSiteAsync(request.SiteId, cancellationToken); + var site = await _loader.LoadSiteAsync(request.SiteId, cancellationToken); if (site == null) { return Result.NotFound($"Site with ID {request.SiteId} was not found."); } - var template = await LoadTemplateAsync(request.TemplateId, cancellationToken); + var template = await _loader.LoadTemplateAsync(request.TemplateId, cancellationToken); if (template == null) { return Result.NotFound($"Shift template with ID {request.TemplateId} was not found."); @@ -112,573 +87,31 @@ public async Task> Handle( return Result.ValidationFailure("Shift template has no shift blocks to apply."); } - var existingShifts = await LoadDayShiftsAsync(request.SiteId, request.Date, cancellationToken); - var employees = await LoadEmployeesAsync(blocks, cancellationToken); - var roleTitles = await LoadRoleTitlesAsync(blocks, cancellationToken); - var availability = await BuildAvailabilityAsync( + var existingShifts = await _loader.LoadDayShiftsAsync(request.SiteId, request.Date, cancellationToken); + var employees = await _loader.LoadEmployeesAsync(blocks, cancellationToken); + var roleTitles = await _loader.LoadRoleTitlesAsync(blocks, cancellationToken); + var availability = await _availability.ResolveAsync( request.SiteId, site, request.Date, employees.Keys, cancellationToken); - var planned = BuildPlannedBlocks(request, template.Id, blocks, employees, roleTitles, availability); - DetectOverlaps(planned, FindOverlapCandidates(planned, existingShifts)); - var deletedIds = ApplyKeepResolution(planned, keep.Value); + var planned = _eligibility.BuildPlannedBlocks( + request.SiteId, request.Date, template.Id, blocks, employees, roleTitles, availability); + _overlaps.DetectOverlaps(planned, existingShifts); + var deletedIds = await _overlaps.ApplyKeepResolutionAsync(planned, keep.Value, cancellationToken); - await PersistAsync(planned, cancellationToken); + await _loader.PersistAsync(planned, cancellationToken); var finalDay = existingShifts.Where(s => !deletedIds.Contains(s.Id)) .Concat(planned.Where(p => !p.Pruned).Select(p => p.Entity)) .ToList(); - var costEmployees = await LoadCostEmployeesAsync(finalDay, employees, cancellationToken); - var weekHours = await LoadWeeklyHoursBeforeDayAsync(finalDay, request.Date, cancellationToken); - var laborCost = CalculateLaborCost(finalDay, costEmployees, weekHours); - var coverage = DetermineCoverageStatus(availability.IsDayOff, finalDay, costEmployees); + var costEmployees = await _loader.LoadCostEmployeesAsync(finalDay, employees, cancellationToken); + var weekHours = await _loader.LoadWeeklyHoursBeforeDayAsync(finalDay, request.Date, cancellationToken); + var laborCost = _analytics.CalculateLaborCost(finalDay, costEmployees, weekHours); + var coverage = _analytics.DetermineCoverageStatus(availability.IsDayOff, finalDay, costEmployees); return Result.Success( BuildResponse(request, planned, laborCost, coverage)); } - private static KeepMode? ParseKeepMode(string? keep) - { - if (string.IsNullOrWhiteSpace(keep)) - { - return KeepMode.None; - } - - if (keep.Trim().Equals("new", StringComparison.OrdinalIgnoreCase)) - { - return KeepMode.KeepNew; - } - - if (keep.Trim().Equals("existing", StringComparison.OrdinalIgnoreCase)) - { - return KeepMode.KeepExisting; - } - - return null; - } - - private async Task LoadSiteAsync(int siteId, CancellationToken cancellationToken) - { - return await _siteRepository.Query(true) - .Include(s => s.OperationalHours) - .FirstOrDefaultAsync(s => s.Id == siteId, cancellationToken); - } - - private async Task LoadTemplateAsync(int templateId, CancellationToken cancellationToken) - { - return await _templateRepository.Query(true) - .Include(t => t.ShiftBlocks) - .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken); - } - - private async Task> LoadDayShiftsAsync( - int siteId, DateOnly date, CancellationToken cancellationToken) - { - var dayStart = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); - var dayEnd = dayStart.AddDays(1); - - return await _shiftRepository.Query(false) - .Where(s => s.SiteId == siteId && s.StartTime < dayEnd && s.EndTime > dayStart) - .OrderBy(s => s.StartTime) - .ToListAsync(cancellationToken); - } - - private async Task> LoadEmployeesAsync( - List blocks, CancellationToken cancellationToken) - { - var ids = blocks - .Where(b => b.EmployeeId.HasValue) - .Select(b => b.EmployeeId!.Value) - .Distinct() - .ToList(); - - if (ids.Count == 0) - { - return new Dictionary(); - } - - return await _employeeRepository.Query(true) - .Include(e => e.PayrollProfile) - .Where(e => ids.Contains(e.Id)) - .ToDictionaryAsync(e => e.Id, cancellationToken); - } - - private async Task> LoadRoleTitlesAsync( - List blocks, CancellationToken cancellationToken) - { - var ids = blocks.Select(b => b.JobRoleId).Distinct().ToList(); - - return await _jobRoleRepository.Query(true) - .Where(r => ids.Contains(r.Id)) - .ToDictionaryAsync(r => r.Id, r => r.Title, cancellationToken); - } - - private async Task BuildAvailabilityAsync( - int siteId, - Site site, - DateOnly date, - IEnumerable candidateIds, - CancellationToken cancellationToken) - { - var ids = candidateIds.ToList(); - var hours = await ResolveOperationalHoursAsync(site, cancellationToken); - hours.TryGetValue(date.DayOfWeek, out var dayHours); - - var context = new AvailabilityContext - { - AuthorizedEmployeeIds = await LoadAuthorizedIdsAsync(siteId, ids, cancellationToken), - IsDayOff = dayHours != null && !dayHours.IsOpen, - DayHours = dayHours, - }; - - await LoadLeaveAsync(context, ids, date, cancellationToken); - return context; - } - - private async Task> LoadAuthorizedIdsAsync( - int siteId, List ids, CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return new HashSet(); - } - - var authorized = await _employeeSiteRepository.Query(true) - .Where(l => l.SiteId == siteId && ids.Contains(l.EmployeeId)) - .Select(l => l.EmployeeId) - .ToListAsync(cancellationToken); - - return authorized.ToHashSet(); - } - - private async Task LoadLeaveAsync( - AvailabilityContext context, List ids, DateOnly date, CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return; - } - - var dayStart = date.ToDateTime(TimeOnly.MinValue); - var dayEnd = dayStart.AddDays(1); - - var requests = await _requestRepository.Query(true) - .Include(r => r.RequestType) - .Where(r => ids.Contains(r.EmployeeId) - && r.StartDate.HasValue && r.StartDate.Value < dayEnd - && (!r.EndDate.HasValue || r.EndDate.Value >= dayStart)) - .ToListAsync(cancellationToken); - - foreach (var req in requests) - { - AddCoveringLeave(context, req, date); - } - - var records = await _attendanceRepository.Query(true) - .Where(a => ids.Contains(a.EmployeeId) && a.Date >= dayStart && a.Date < dayEnd) - .ToListAsync(cancellationToken); - - AddLeaveRecords(context, records); - } - - private static void AddCoveringLeave(AvailabilityContext context, Request req, DateOnly date) - { - if (!LeaveStatusHelper.IsApprovedLeaveStatus(req.Status) - || !LeaveStatusHelper.CoversDate(req, date)) - { - return; - } - - context.LeaveRequests.Add(req); - context.LeaveTypeByEmployeeId[req.EmployeeId] = LeaveStatusHelper.FormatLeaveType(req); - context.RemoteWorkByEmployeeId[req.EmployeeId] = LeaveStatusHelper.IsRemoteWorkStatus(req.Status); - } - - private static void AddLeaveRecords(AvailabilityContext context, List records) - { - foreach (var record in records) - { - if (IsLeaveRecord(record)) - { - context.LeaveRecordEmployeeIds.Add(record.EmployeeId); - } - } - } - - private static bool IsLeaveRecord(AttendanceRecord record) - { - return record.Status is AttendanceDayStatus.ApprovedLeave - or AttendanceDayStatus.UnapprovedLeave - or AttendanceDayStatus.PartialLeave; - } - - private async Task> ResolveOperationalHoursAsync( - Site site, CancellationToken cancellationToken) - { - var fromRepo = await _operationalHourRepository.Query(true) - .Where(o => o.SiteId == site.Id) - .ToListAsync(cancellationToken); - - if (fromRepo.Count > 0) - { - return fromRepo.GroupBy(o => o.DayOfWeek).ToDictionary(g => g.Key, g => g.First()); - } - - var source = site.OperationalHours ?? Enumerable.Empty(); - return source.GroupBy(o => o.DayOfWeek).ToDictionary(g => g.Key, g => g.First()); - } - - private List BuildPlannedBlocks( - ApplyTemplateCommand request, - int templateId, - List blocks, - Dictionary employees, - Dictionary roleTitles, - AvailabilityContext availability) - { - var planned = new List(blocks.Count); - - foreach (var block in blocks) - { - var roleTitle = ResolveRoleTitle(block.JobRoleId, roleTitles); - employees.TryGetValue(block.EmployeeId ?? -1, out var employee); - var strip = ResolveStrip(block, employee, roleTitle, request.Date, availability); - - var start = ToDateTimeOffset(request.Date, block.StartTime); - var end = ToDateTimeOffset(request.Date, block.EndTime); - if (end <= start) - { - end = end.AddDays(1); - } - - var entity = new ShiftEntity - { - SiteId = request.SiteId, - JobRoleId = block.JobRoleId, - StartTime = start, - EndTime = end, - IsPublished = false, - Status = ShiftStatus.Draft, - EmployeeId = strip != null ? null : employee!.Id, - ShiftTemplateId = templateId, - }; - - planned.Add(new PlannedBlock - { - Entity = entity, - RoleTitle = roleTitle, - SourceEmployeeId = block.EmployeeId, - SourceEmployeeName = employee == null ? null : DisplayName(employee), - AssignedEmployeeName = strip != null || employee == null ? null : DisplayName(employee), - Stripped = strip != null, - StripCode = strip?.Code, - StripReason = strip?.Reason, - }); - } - - return planned; - } - - private static string ResolveRoleTitle(int jobRoleId, Dictionary roleTitles) - { - return roleTitles.TryGetValue(jobRoleId, out var title) ? title : $"Role {jobRoleId}"; - } - - private static DateTimeOffset ToDateTimeOffset(DateOnly date, TimeSpan time) - { - return new DateTimeOffset(date.ToDateTime(TimeOnly.FromTimeSpan(time)), TimeSpan.Zero); - } - - private static StripDecision? ResolveStrip( - ShiftBlock block, - Employee? employee, - string roleTitle, - DateOnly date, - AvailabilityContext availability) - { - return ResolveStripIdentity(block, employee, roleTitle, availability) - ?? ResolveStripAvailability(block, employee!, roleTitle, date, availability); - } - - private static StripDecision? ResolveStripIdentity( - ShiftBlock block, - Employee? employee, - string roleTitle, - AvailabilityContext availability) - { - if (block.EmployeeId == null || employee == null) - { - return new StripDecision( - ApplyTemplateStripCodes.EmployeeNotFound, - "Employee assigned to template block is not found."); - } - - if (employee.IsDeleted || !employee.IsActive) - { - return new StripDecision( - ApplyTemplateStripCodes.EmployeeInactive, - $"{DisplayName(employee)} is inactive and cannot be scheduled."); - } - - if (!availability.AuthorizedEmployeeIds.Contains(employee.Id)) - { - return new StripDecision( - ApplyTemplateStripCodes.SiteUnauthorized, - "Employee is unavailable at target site."); - } - - return null; - } - - private static StripDecision? ResolveStripAvailability( - ShiftBlock block, - Employee employee, - string roleTitle, - DateOnly date, - AvailabilityContext availability) - { - if (availability.IsDayOff) - { - return new StripDecision( - ApplyTemplateStripCodes.SiteClosed, - $"Site is closed on {date.DayOfWeek}."); - } - - var hoursStrip = StripForSiteHours(block, availability.DayHours); - if (hoursStrip != null) - { - return hoursStrip; - } - - var leaveStrip = StripForLeave(employee, date, availability); - if (leaveStrip != null) - { - return leaveStrip; - } - - if (WeeklyAvailabilityHelper.Evaluate( - employee.OnlineWorkdaysJson, employee.OfflineWorkdaysJson, date.DayOfWeek) - == WeeklyAvailability.Unavailable) - { - return new StripDecision( - ApplyTemplateStripCodes.OutsideEmployeeAvailability, - $"{DisplayName(employee)} is unavailable on {date.DayOfWeek} per weekly availability."); - } - - if (employee.JobRoleId != block.JobRoleId) - { - return new StripDecision( - ApplyTemplateStripCodes.QualificationMismatch, - $"{DisplayName(employee)} job role does not match required role {roleTitle}."); - } - - return null; - } - - private static StripDecision? StripForSiteHours(ShiftBlock block, SiteOperationalHour? dayHours) - { - if (dayHours == null) - { - return null; - } - - var open = dayHours.OpenTime.ToTimeSpan(); - var close = dayHours.CloseTime.ToTimeSpan(); - - if (block.StartTime < open || block.EndTime > close) - { - return new StripDecision( - ApplyTemplateStripCodes.OutsideOperationalHours, - "Block time falls outside site operational hours."); - } - - return null; - } - - private static StripDecision? StripForLeave( - Employee employee, DateOnly date, AvailabilityContext availability) - { - if (availability.LeaveTypeByEmployeeId.TryGetValue(employee.Id, out var leaveType)) - { - var isRemote = availability.RemoteWorkByEmployeeId.TryGetValue(employee.Id, out var remote) && remote; - var code = isRemote - ? ApplyTemplateStripCodes.EmployeeRemoteWork - : ApplyTemplateStripCodes.EmployeeOnLeave; - var kind = isRemote ? "remote work" : "approved leave"; - return new StripDecision(code, $"On {kind} ({leaveType}) on {date:yyyy-MM-dd}."); - } - - if (availability.LeaveRecordEmployeeIds.Contains(employee.Id)) - { - return new StripDecision( - ApplyTemplateStripCodes.EmployeeOnLeave, - $"On approved leave on {date:yyyy-MM-dd}."); - } - - return null; - } - - private static string DisplayName(Employee employee) - { - return $"{employee.FirstName} {employee.LastName}".Trim(); - } - - private static List FindOverlapCandidates( - List planned, List existingShifts) - { - if (planned.Count == 0) - { - return new List(); - } - - if (existingShifts.Count == 0) - { - return new List(); - } - - var minStart = planned.Min(p => p.Entity.StartTime); - var maxEnd = planned.Max(p => p.Entity.EndTime); - - return existingShifts - .Where(s => s.StartTime < maxEnd && s.EndTime > minStart) - .ToList(); - } - - private static void DetectOverlaps(List planned, List candidates) - { - var seen = new List(candidates); - var existingIds = candidates.Select(s => s.Id).ToHashSet(); - - foreach (var block in planned) - { - FlagBlockOverlaps(block, seen, existingIds); - seen.Add(block.Entity); - } - } - - private static void FlagBlockOverlaps( - PlannedBlock block, List seen, HashSet existingIds) - { - foreach (var other in seen) - { - if (!Overlaps(block.Entity, other)) - { - continue; - } - - if (existingIds.Contains(other.Id)) - { - block.OverlappingExistingIds.Add(other.Id); - } - - FlagCollisionOrConflict(block, other); - } - } - - private static void FlagCollisionOrConflict(PlannedBlock block, ShiftEntity other) - { - if (block.Entity.EmployeeId.HasValue && other.EmployeeId == block.Entity.EmployeeId) - { - block.Collision = true; - block.CollisionType = ApplyTemplateCollisionTypes.EmployeeOverlap; - } - else - { - block.Conflict = true; - block.ConflictType = ApplyTemplateConflictTypes.RoleSlotConflict; - } - } - - private static bool Overlaps(ShiftEntity first, ShiftEntity second) - { - return first.StartTime < second.EndTime && second.StartTime < first.EndTime; - } - - private HashSet ApplyKeepResolution(List planned, KeepMode keep) - { - var deletedIds = new HashSet(); - - if (keep == KeepMode.KeepNew) - { - DeleteOverlappingExisting(planned, deletedIds); - } - - if (keep == KeepMode.KeepExisting) - { - PruneCollidingNew(planned); - } - - return deletedIds; - } - - private void DeleteOverlappingExisting(List planned, HashSet deletedIds) - { - var targets = planned - .SelectMany(p => p.OverlappingExistingIds) - .Distinct() - .ToList(); - - if (targets.Count == 0) - { - return; - } - - foreach (var shift in _shiftRepository.Query(false).Where(s => targets.Contains(s.Id)).ToList()) - { - _shiftRepository.Delete(shift); - deletedIds.Add(shift.Id); - } - } - - private static void PruneCollidingNew(List planned) - { - foreach (var block in planned) - { - if (block.Collision || block.Conflict) - { - block.Pruned = true; - } - } - } - - private async Task PersistAsync(List planned, CancellationToken cancellationToken) - { - foreach (var block in planned.Where(p => !p.Pruned)) - { - await _shiftRepository.AddAsync(block.Entity, cancellationToken); - } - - await _unitOfWork.SaveChangesAsync(cancellationToken); - } - - private async Task> LoadCostEmployeesAsync( - List finalDay, - Dictionary known, - CancellationToken cancellationToken) - { - var missingIds = finalDay - .Where(s => s.EmployeeId.HasValue && !known.ContainsKey(s.EmployeeId.Value)) - .Select(s => s.EmployeeId!.Value) - .Distinct() - .ToList(); - - if (missingIds.Count == 0) - { - return new Dictionary(known); - } - - var missing = await _employeeRepository.Query(true) - .Include(e => e.PayrollProfile) - .Where(e => missingIds.Contains(e.Id)) - .ToDictionaryAsync(e => e.Id, cancellationToken); - - foreach (var entry in missing) - { - known[entry.Key] = entry.Value; - } - - return known; - } - private static ApplyTemplateResponseDto BuildResponse( ApplyTemplateCommand request, List planned, @@ -692,12 +125,7 @@ private static ApplyTemplateResponseDto BuildResponse( Blocks: planned.Select(MapBlock).ToList(), Warnings: planned .Where(p => p.Stripped) - .Select(p => new ApplyTemplateWarningDto( - EmployeeId: p.SourceEmployeeId, - EmployeeName: p.SourceEmployeeName ?? "Unknown", - Role: p.RoleTitle, - Reason: p.StripReason ?? string.Empty, - Code: p.StripCode ?? string.Empty)) + .Select(MapWarning) .ToList(), TotalLaborCost: laborCost.Total, RegularCost: laborCost.Regular, @@ -729,148 +157,13 @@ private static AppliedTemplateBlockDto MapBlock(PlannedBlock block) Pruned: block.Pruned); } - private async Task> LoadWeeklyHoursBeforeDayAsync( - List finalDay, DateOnly date, CancellationToken cancellationToken) - { - var ids = finalDay - .Where(s => s.EmployeeId.HasValue) - .Select(s => s.EmployeeId!.Value) - .Distinct() - .ToList(); - - if (ids.Count == 0) - { - return new Dictionary(); - } - - var (weekStart, _) = GetWeekBoundary(date); - var dayStart = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); - - var shifts = await _shiftRepository.Query(true) - .Where(s => s.EmployeeId != null - && ids.Contains(s.EmployeeId.Value) - && s.StartTime >= weekStart - && s.StartTime < dayStart) - .ToListAsync(cancellationToken); - - return ids.ToDictionary( - id => id, - id => shifts - .Where(s => s.EmployeeId == id) - .Sum(s => (decimal)(s.EndTime - s.StartTime).TotalHours)); - } - - private static (DateTimeOffset WeekStart, DateTimeOffset WeekEnd) GetWeekBoundary(DateOnly targetDate) - { - int diff = (7 + (int)targetDate.DayOfWeek - (int)DayOfWeek.Monday) % 7; - var monday = targetDate.AddDays(-diff); - var sunday = monday.AddDays(6); - - var weekStart = new DateTimeOffset(monday.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); - var weekEnd = new DateTimeOffset(sunday.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); - - return (weekStart, weekEnd); - } - - private static decimal CalculateHourlyRate(PayrollProfile? payroll) - { - if (payroll == null) - { - return 0m; - } - - if (string.Equals(payroll.SalaryType, "Monthly", StringComparison.OrdinalIgnoreCase) && payroll.PaymentAmount > 0) - { - return Math.Round(payroll.PaymentAmount / 160m, 2); - } - - return payroll.PaymentAmount; - } - - private static (decimal Total, decimal Regular, decimal Overtime) CalculateLaborCost( - IReadOnlyList dayShifts, - IReadOnlyDictionary employees, - IReadOnlyDictionary weekHoursBefore) - { - decimal regular = 0m; - decimal overtime = 0m; - - var assigned = dayShifts.Where(s => s.EmployeeId.HasValue).GroupBy(s => s.EmployeeId!.Value); - foreach (var group in assigned) - { - if (!employees.TryGetValue(group.Key, out var emp)) - { - continue; - } - - PriceEmployeeDay(group, emp, weekHoursBefore, ref regular, ref overtime); - } - - return (Math.Round(regular + overtime, 2), Math.Round(regular, 2), Math.Round(overtime, 2)); - } - - private static void PriceEmployeeDay( - IEnumerable dayShifts, - Employee employee, - IReadOnlyDictionary weekHoursBefore, - ref decimal regular, - ref decimal overtime) - { - var rate = CalculateHourlyRate(employee.PayrollProfile); - var overtimeRate = employee.PayrollProfile?.OvertimeHourlyRate > 0 - ? employee.PayrollProfile.OvertimeHourlyRate - : rate * 1.5m; - var threshold = employee.PayrollProfile?.OvertimeThresholdHours > 0 - ? employee.PayrollProfile.OvertimeThresholdHours - : 40m; - var cumulative = weekHoursBefore.TryGetValue(employee.Id, out var before) ? before : 0m; - - foreach (var shift in dayShifts.OrderBy(s => s.StartTime)) - { - var duration = (decimal)Math.Max(0, (shift.EndTime - shift.StartTime).TotalHours); - var regularHours = Math.Max(0, Math.Min(duration, threshold - cumulative)); - regular += regularHours * rate; - overtime += (duration - regularHours) * overtimeRate; - cumulative += duration; - } - } - - private static WeekDayCalendarStatus DetermineCoverageStatus( - bool isDayOff, - IReadOnlyList dayShifts, - IReadOnlyDictionary employees) - { - if (isDayOff) - { - return WeekDayCalendarStatus.DimmedDayOff; - } - - if (dayShifts.Count == 0) - { - return WeekDayCalendarStatus.NoAllocations; - } - - if (HasOvertimeOrMisallocation(dayShifts, employees)) - { - return WeekDayCalendarStatus.OvertimeOrMisallocation; - } - - if (dayShifts.Any(s => s.EmployeeId == null || !s.IsPublished)) - { - return WeekDayCalendarStatus.MissingResourcesOrUnpublished; - } - - return WeekDayCalendarStatus.CoveredAndPublished; - } - - private static bool HasOvertimeOrMisallocation( - IReadOnlyList shifts, - IReadOnlyDictionary employees) + private static ApplyTemplateWarningDto MapWarning(PlannedBlock block) { - return shifts.Any(s => - (s.EndTime - s.StartTime).TotalHours > 8.0 || - (s.EmployeeId != null && - employees.TryGetValue(s.EmployeeId.Value, out var emp) && - emp.JobRoleId != s.JobRoleId)); + return new ApplyTemplateWarningDto( + EmployeeId: block.SourceEmployeeId, + EmployeeName: block.SourceEmployeeName ?? "Unknown", + Role: block.RoleTitle, + Reason: block.StripReason ?? string.Empty, + Code: block.StripCode ?? string.Empty); } } diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityContext.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityContext.cs new file mode 100644 index 0000000..ab697af --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityContext.cs @@ -0,0 +1,14 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class AvailabilityContext +{ + public HashSet AuthorizedEmployeeIds { get; set; } = new(); + public bool IsDayOff { get; set; } + public SiteOperationalHour? DayHours { get; set; } + public List LeaveRequests { get; set; } = new(); + public HashSet LeaveRecordEmployeeIds { get; set; } = new(); + public Dictionary LeaveTypeByEmployeeId { get; set; } = new(); + public Dictionary RemoteWorkByEmployeeId { get; set; } = new(); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityResolver.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityResolver.cs new file mode 100644 index 0000000..afab4ee --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/AvailabilityResolver.cs @@ -0,0 +1,142 @@ +using Buy2.Application.Common.Helpers; +using Buy2.Application.Common.Interfaces; +using Buy2.Application.Common.Specifications; +using Buy2.Domain.Entities; +using Buy2.Domain.Enums; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class AvailabilityResolver : IAvailabilityResolver +{ + private readonly IRepository _employeeSiteRepository; + private readonly IRepository _operationalHourRepository; + private readonly IRepository _requestRepository; + private readonly IRepository _attendanceRepository; + + public AvailabilityResolver( + IRepository employeeSiteRepository, + IRepository operationalHourRepository, + IRepository requestRepository, + IRepository attendanceRepository) + { + _employeeSiteRepository = employeeSiteRepository; + _operationalHourRepository = operationalHourRepository; + _requestRepository = requestRepository; + _attendanceRepository = attendanceRepository; + } + + public async Task ResolveAsync( + int siteId, + Site site, + DateOnly date, + IEnumerable candidateEmployeeIds, + CancellationToken cancellationToken = default) + { + var ids = candidateEmployeeIds.ToList(); + var hours = await ResolveOperationalHoursAsync(site, cancellationToken); + hours.TryGetValue(date.DayOfWeek, out var dayHours); + + var context = new AvailabilityContext + { + AuthorizedEmployeeIds = await LoadAuthorizedIdsAsync(siteId, ids, cancellationToken), + IsDayOff = dayHours != null && !dayHours.IsOpen, + DayHours = dayHours, + }; + + await LoadLeaveAsync(context, ids, date, cancellationToken); + return context; + } + + private async Task> LoadAuthorizedIdsAsync( + int siteId, List ids, CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return new HashSet(); + } + + var specification = new Specification() + .Where(l => l.SiteId == siteId && ids.Contains(l.EmployeeId)); + + var authorized = await _employeeSiteRepository.ListAsync( + specification, l => l.EmployeeId, cancellationToken); + + return authorized.ToHashSet(); + } + + private async Task LoadLeaveAsync( + AvailabilityContext context, List ids, DateOnly date, CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return; + } + + var dayStart = date.ToDateTime(TimeOnly.MinValue); + var dayEnd = dayStart.AddDays(1); + + var requests = await _requestRepository.ListAsync( + r => ids.Contains(r.EmployeeId) + && r.StartDate.HasValue && r.StartDate.Value < dayEnd + && (!r.EndDate.HasValue || r.EndDate.Value >= dayStart), + cancellationToken, + nameof(Request.RequestType)); + + foreach (var req in requests) + { + AddCoveringLeave(context, req, date); + } + + var records = await _attendanceRepository.ListAsync( + a => ids.Contains(a.EmployeeId) && a.Date >= dayStart && a.Date < dayEnd, + cancellationToken); + + AddLeaveRecords(context, records); + } + + private static void AddCoveringLeave(AvailabilityContext context, Request req, DateOnly date) + { + if (!LeaveStatusHelper.IsApprovedLeaveStatus(req.Status) + || !LeaveStatusHelper.CoversDate(req, date)) + { + return; + } + + context.LeaveRequests.Add(req); + context.LeaveTypeByEmployeeId[req.EmployeeId] = LeaveStatusHelper.FormatLeaveType(req); + context.RemoteWorkByEmployeeId[req.EmployeeId] = LeaveStatusHelper.IsRemoteWorkStatus(req.Status); + } + + private static void AddLeaveRecords(AvailabilityContext context, List records) + { + foreach (var record in records) + { + if (IsLeaveRecord(record)) + { + context.LeaveRecordEmployeeIds.Add(record.EmployeeId); + } + } + } + + private static bool IsLeaveRecord(AttendanceRecord record) + { + return record.Status is AttendanceDayStatus.ApprovedLeave + or AttendanceDayStatus.UnapprovedLeave + or AttendanceDayStatus.PartialLeave; + } + + private async Task> ResolveOperationalHoursAsync( + Site site, CancellationToken cancellationToken) + { + var fromRepo = await _operationalHourRepository.ListAsync( + o => o.SiteId == site.Id, cancellationToken); + + if (fromRepo.Count > 0) + { + return fromRepo.GroupBy(o => o.DayOfWeek).ToDictionary(g => g.Key, g => g.First()); + } + + var source = site.OperationalHours ?? Enumerable.Empty(); + return source.GroupBy(o => o.DayOfWeek).ToDictionary(g => g.Key, g => g.First()); + } +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/EligibilityEvaluator.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/EligibilityEvaluator.cs new file mode 100644 index 0000000..d88d7b4 --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/EligibilityEvaluator.cs @@ -0,0 +1,205 @@ +using Buy2.Application.Common.Helpers; +using Buy2.Application.DTOs.Schedules; +using Buy2.Domain.Entities; +using Buy2.Domain.Enums; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class EligibilityEvaluator : IEligibilityEvaluator +{ + public List BuildPlannedBlocks( + int siteId, + DateOnly date, + int templateId, + List blocks, + Dictionary employees, + Dictionary roleTitles, + AvailabilityContext availability) + { + var planned = new List(blocks.Count); + + foreach (var block in blocks) + { + var roleTitle = ResolveRoleTitle(block.JobRoleId, roleTitles); + employees.TryGetValue(block.EmployeeId ?? -1, out var employee); + var strip = ResolveStrip(block, employee, roleTitle, date, availability); + + var start = ToDateTimeOffset(date, block.StartTime); + var end = ToDateTimeOffset(date, block.EndTime); + if (end <= start) + { + end = end.AddDays(1); + } + + var entity = new ShiftEntity + { + SiteId = siteId, + JobRoleId = block.JobRoleId, + StartTime = start, + EndTime = end, + IsPublished = false, + Status = ShiftStatus.Draft, + EmployeeId = strip != null ? null : employee!.Id, + ShiftTemplateId = templateId, + }; + + planned.Add(new PlannedBlock + { + Entity = entity, + RoleTitle = roleTitle, + SourceEmployeeId = block.EmployeeId, + SourceEmployeeName = employee == null ? null : DisplayName(employee), + AssignedEmployeeName = strip != null || employee == null ? null : DisplayName(employee), + Stripped = strip != null, + StripCode = strip?.Code, + StripReason = strip?.Reason, + }); + } + + return planned; + } + + private static string ResolveRoleTitle(int jobRoleId, Dictionary roleTitles) + { + return roleTitles.TryGetValue(jobRoleId, out var title) ? title : $"Role {jobRoleId}"; + } + + private static DateTimeOffset ToDateTimeOffset(DateOnly date, TimeSpan time) + { + return new DateTimeOffset(date.ToDateTime(TimeOnly.FromTimeSpan(time)), TimeSpan.Zero); + } + + private static StripDecision? ResolveStrip( + ShiftBlock block, + Employee? employee, + string roleTitle, + DateOnly date, + AvailabilityContext availability) + { + return ResolveStripIdentity(block, employee, roleTitle, availability) + ?? ResolveStripAvailability(block, employee!, roleTitle, date, availability); + } + + private static StripDecision? ResolveStripIdentity( + ShiftBlock block, + Employee? employee, + string roleTitle, + AvailabilityContext availability) + { + if (block.EmployeeId == null || employee == null) + { + return new StripDecision( + ApplyTemplateStripCodes.EmployeeNotFound, + "Employee assigned to template block is not found."); + } + + if (employee.IsDeleted || !employee.IsActive) + { + return new StripDecision( + ApplyTemplateStripCodes.EmployeeInactive, + $"{DisplayName(employee)} is inactive and cannot be scheduled."); + } + + if (!availability.AuthorizedEmployeeIds.Contains(employee.Id)) + { + return new StripDecision( + ApplyTemplateStripCodes.SiteUnauthorized, + "Employee is unavailable at target site."); + } + + return null; + } + + private static StripDecision? ResolveStripAvailability( + ShiftBlock block, + Employee employee, + string roleTitle, + DateOnly date, + AvailabilityContext availability) + { + if (availability.IsDayOff) + { + return new StripDecision( + ApplyTemplateStripCodes.SiteClosed, + $"Site is closed on {date.DayOfWeek}."); + } + + var hoursStrip = StripForSiteHours(block, availability.DayHours); + if (hoursStrip != null) + { + return hoursStrip; + } + + var leaveStrip = StripForLeave(employee, date, availability); + if (leaveStrip != null) + { + return leaveStrip; + } + + if (WeeklyAvailabilityHelper.Evaluate( + employee.OnlineWorkdaysJson, employee.OfflineWorkdaysJson, date.DayOfWeek) + == WeeklyAvailability.Unavailable) + { + return new StripDecision( + ApplyTemplateStripCodes.OutsideEmployeeAvailability, + $"{DisplayName(employee)} is unavailable on {date.DayOfWeek} per weekly availability."); + } + + if (employee.JobRoleId != block.JobRoleId) + { + return new StripDecision( + ApplyTemplateStripCodes.QualificationMismatch, + $"{DisplayName(employee)} job role does not match required role {roleTitle}."); + } + + return null; + } + + private static StripDecision? StripForSiteHours(ShiftBlock block, SiteOperationalHour? dayHours) + { + if (dayHours == null) + { + return null; + } + + var open = dayHours.OpenTime.ToTimeSpan(); + var close = dayHours.CloseTime.ToTimeSpan(); + + if (block.StartTime < open || block.EndTime > close) + { + return new StripDecision( + ApplyTemplateStripCodes.OutsideOperationalHours, + "Block time falls outside site operational hours."); + } + + return null; + } + + private static StripDecision? StripForLeave( + Employee employee, DateOnly date, AvailabilityContext availability) + { + if (availability.LeaveTypeByEmployeeId.TryGetValue(employee.Id, out var leaveType)) + { + var isRemote = availability.RemoteWorkByEmployeeId.TryGetValue(employee.Id, out var remote) && remote; + var code = isRemote + ? ApplyTemplateStripCodes.EmployeeRemoteWork + : ApplyTemplateStripCodes.EmployeeOnLeave; + var kind = isRemote ? "remote work" : "approved leave"; + return new StripDecision(code, $"On {kind} ({leaveType}) on {date:yyyy-MM-dd}."); + } + + if (availability.LeaveRecordEmployeeIds.Contains(employee.Id)) + { + return new StripDecision( + ApplyTemplateStripCodes.EmployeeOnLeave, + $"On approved leave on {date:yyyy-MM-dd}."); + } + + return null; + } + + private static string DisplayName(Employee employee) + { + return $"{employee.FirstName} {employee.LastName}".Trim(); + } +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IAvailabilityResolver.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IAvailabilityResolver.cs new file mode 100644 index 0000000..03a7aad --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IAvailabilityResolver.cs @@ -0,0 +1,17 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +/// +/// Resolves employee availability for a site/day: +/// authorization + operational hours + approved leave / remote / attendance records. +/// +public interface IAvailabilityResolver +{ + Task ResolveAsync( + int siteId, + Site site, + DateOnly date, + IEnumerable candidateEmployeeIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IEligibilityEvaluator.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IEligibilityEvaluator.cs new file mode 100644 index 0000000..17d4116 --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IEligibilityEvaluator.cs @@ -0,0 +1,20 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +/// +/// Pure eligibility rules: which template blocks keep their assignment +/// and which are stripped to open roles (with code + reason). +/// No repository access — fully unit-testable. +/// +public interface IEligibilityEvaluator +{ + List BuildPlannedBlocks( + int siteId, + DateOnly date, + int templateId, + List blocks, + Dictionary employees, + Dictionary roleTitles, + AvailabilityContext availability); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IOverlapResolver.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IOverlapResolver.cs new file mode 100644 index 0000000..dff18b0 --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IOverlapResolver.cs @@ -0,0 +1,19 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +/// +/// Overlap detection + keep-policy resolution (new / existing). +/// Owns only ShiftEntity persistence for the KeepNew delete path. +/// +public interface IOverlapResolver +{ + KeepMode? ParseKeepMode(string? keep); + + void DetectOverlaps(List planned, List existingShifts); + + Task> ApplyKeepResolutionAsync( + List planned, + KeepMode keep, + CancellationToken cancellationToken = default); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IScheduleAnalyticsService.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IScheduleAnalyticsService.cs new file mode 100644 index 0000000..788772c --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/IScheduleAnalyticsService.cs @@ -0,0 +1,21 @@ +using Buy2.Application.DTOs.Schedules; +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +/// +/// Pure analytics: labor-cost pricing + coverage status. +/// No repository access — fully unit-testable. +/// +public interface IScheduleAnalyticsService +{ + (decimal Total, decimal Regular, decimal Overtime) CalculateLaborCost( + IReadOnlyList dayShifts, + IReadOnlyDictionary employees, + IReadOnlyDictionary weekHoursBefore); + + WeekDayCalendarStatus DetermineCoverageStatus( + bool isDayOff, + IReadOnlyList dayShifts, + IReadOnlyDictionary employees); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ITemplateApplicationLoader.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ITemplateApplicationLoader.cs new file mode 100644 index 0000000..bf1a2f1 --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ITemplateApplicationLoader.cs @@ -0,0 +1,31 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +/// +/// Data-access boundary for ApplyTemplate: all persistence reads + persistence. +/// Keeps query composition (specifications) in one place so future ORM/paging +/// changes don't ripple into business rules. +/// +public interface ITemplateApplicationLoader +{ + Task LoadSiteAsync(int siteId, CancellationToken cancellationToken = default); + + Task LoadTemplateAsync(int templateId, CancellationToken cancellationToken = default); + + Task> LoadDayShiftsAsync(int siteId, DateOnly date, CancellationToken cancellationToken = default); + + Task> LoadEmployeesAsync(List blocks, CancellationToken cancellationToken = default); + + Task> LoadRoleTitlesAsync(List blocks, CancellationToken cancellationToken = default); + + Task> LoadCostEmployeesAsync( + List finalDay, + Dictionary known, + CancellationToken cancellationToken = default); + + Task> LoadWeeklyHoursBeforeDayAsync( + List finalDay, DateOnly date, CancellationToken cancellationToken = default); + + Task PersistAsync(IEnumerable planned, CancellationToken cancellationToken = default); +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/KeepMode.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/KeepMode.cs new file mode 100644 index 0000000..b3d74f2 --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/KeepMode.cs @@ -0,0 +1,8 @@ +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public enum KeepMode +{ + None, + KeepNew, + KeepExisting +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/OverlapResolver.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/OverlapResolver.cs new file mode 100644 index 0000000..b7b0d2b --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/OverlapResolver.cs @@ -0,0 +1,167 @@ +using Buy2.Application.Common.Interfaces; +using Buy2.Application.Common.Specifications; +using Buy2.Application.DTOs.Schedules; +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class OverlapResolver : IOverlapResolver +{ + private readonly IRepository _shiftRepository; + + public OverlapResolver(IRepository shiftRepository) + { + _shiftRepository = shiftRepository; + } + + public KeepMode? ParseKeepMode(string? keep) + { + if (string.IsNullOrWhiteSpace(keep)) + { + return KeepMode.None; + } + + if (keep.Trim().Equals("new", StringComparison.OrdinalIgnoreCase)) + { + return KeepMode.KeepNew; + } + + if (keep.Trim().Equals("existing", StringComparison.OrdinalIgnoreCase)) + { + return KeepMode.KeepExisting; + } + + return null; + } + + public void DetectOverlaps(List planned, List existingShifts) + { + var candidates = FindOverlapCandidates(planned, existingShifts); + var seen = new List(candidates); + var existingIds = candidates.Select(s => s.Id).ToHashSet(); + + foreach (var block in planned) + { + FlagBlockOverlaps(block, seen, existingIds); + seen.Add(block.Entity); + } + } + + public async Task> ApplyKeepResolutionAsync( + List planned, + KeepMode keep, + CancellationToken cancellationToken = default) + { + var deletedIds = new HashSet(); + + if (keep == KeepMode.KeepNew) + { + await DeleteOverlappingExistingAsync(planned, deletedIds, cancellationToken); + } + + if (keep == KeepMode.KeepExisting) + { + PruneCollidingNew(planned); + } + + return deletedIds; + } + + private static List FindOverlapCandidates( + List planned, List existingShifts) + { + if (planned.Count == 0) + { + return new List(); + } + + if (existingShifts.Count == 0) + { + return new List(); + } + + var minStart = planned.Min(p => p.Entity.StartTime); + var maxEnd = planned.Max(p => p.Entity.EndTime); + + return existingShifts + .Where(s => s.StartTime < maxEnd && s.EndTime > minStart) + .ToList(); + } + + private static void FlagBlockOverlaps( + PlannedBlock block, List seen, HashSet existingIds) + { + foreach (var other in seen) + { + if (!Overlaps(block.Entity, other)) + { + continue; + } + + if (existingIds.Contains(other.Id)) + { + block.OverlappingExistingIds.Add(other.Id); + } + + FlagCollisionOrConflict(block, other); + } + } + + private static void FlagCollisionOrConflict(PlannedBlock block, ShiftEntity other) + { + if (block.Entity.EmployeeId.HasValue && other.EmployeeId == block.Entity.EmployeeId) + { + block.Collision = true; + block.CollisionType = ApplyTemplateCollisionTypes.EmployeeOverlap; + } + else + { + block.Conflict = true; + block.ConflictType = ApplyTemplateConflictTypes.RoleSlotConflict; + } + } + + private static bool Overlaps(ShiftEntity first, ShiftEntity second) + { + return first.StartTime < second.EndTime && second.StartTime < first.EndTime; + } + + private async Task DeleteOverlappingExistingAsync( + List planned, + HashSet deletedIds, + CancellationToken cancellationToken) + { + var targets = planned + .SelectMany(p => p.OverlappingExistingIds) + .Distinct() + .ToList(); + + if (targets.Count == 0) + { + return; + } + + var specification = new Specification() + .Where(s => targets.Contains(s.Id)) + .AsTracked(); + + var shifts = await _shiftRepository.ListAsync(specification, cancellationToken); + + foreach (var shift in shifts) + { + _shiftRepository.Delete(shift); + deletedIds.Add(shift.Id); + } + } + + private static void PruneCollidingNew(List planned) + { + foreach (var block in planned) + { + if (block.Collision || block.Conflict) + { + block.Pruned = true; + } + } + } +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/PlannedBlock.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/PlannedBlock.cs new file mode 100644 index 0000000..fa3399e --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/PlannedBlock.cs @@ -0,0 +1,23 @@ +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class PlannedBlock +{ + public ShiftEntity Entity { get; set; } = null!; + public string RoleTitle { get; set; } = string.Empty; + public int? SourceEmployeeId { get; set; } + public string? SourceEmployeeName { get; set; } + public string? AssignedEmployeeName { get; set; } + public bool Stripped { get; set; } + public string? StripCode { get; set; } + public string? StripReason { get; set; } + public bool Collision { get; set; } + public string? CollisionType { get; set; } + public bool Conflict { get; set; } + public string? ConflictType { get; set; } + public bool Pruned { get; set; } + public HashSet OverlappingExistingIds { get; } = new(); +} + +public sealed record StripDecision(string Code, string Reason); diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ScheduleAnalyticsService.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ScheduleAnalyticsService.cs new file mode 100644 index 0000000..d8a269b --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/ScheduleAnalyticsService.cs @@ -0,0 +1,109 @@ +using Buy2.Application.DTOs.Schedules; +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class ScheduleAnalyticsService : IScheduleAnalyticsService +{ + public (decimal Total, decimal Regular, decimal Overtime) CalculateLaborCost( + IReadOnlyList dayShifts, + IReadOnlyDictionary employees, + IReadOnlyDictionary weekHoursBefore) + { + decimal regular = 0m; + decimal overtime = 0m; + + var assigned = dayShifts.Where(s => s.EmployeeId.HasValue).GroupBy(s => s.EmployeeId!.Value); + foreach (var group in assigned) + { + if (!employees.TryGetValue(group.Key, out var emp)) + { + continue; + } + + PriceEmployeeDay(group, emp, weekHoursBefore, ref regular, ref overtime); + } + + return (Math.Round(regular + overtime, 2), Math.Round(regular, 2), Math.Round(overtime, 2)); + } + + public WeekDayCalendarStatus DetermineCoverageStatus( + bool isDayOff, + IReadOnlyList dayShifts, + IReadOnlyDictionary employees) + { + if (isDayOff) + { + return WeekDayCalendarStatus.DimmedDayOff; + } + + if (dayShifts.Count == 0) + { + return WeekDayCalendarStatus.NoAllocations; + } + + if (HasOvertimeOrMisallocation(dayShifts, employees)) + { + return WeekDayCalendarStatus.OvertimeOrMisallocation; + } + + if (dayShifts.Any(s => s.EmployeeId == null || !s.IsPublished)) + { + return WeekDayCalendarStatus.MissingResourcesOrUnpublished; + } + + return WeekDayCalendarStatus.CoveredAndPublished; + } + + private static void PriceEmployeeDay( + IEnumerable dayShifts, + Employee employee, + IReadOnlyDictionary weekHoursBefore, + ref decimal regular, + ref decimal overtime) + { + var rate = CalculateHourlyRate(employee.PayrollProfile); + var overtimeRate = employee.PayrollProfile?.OvertimeHourlyRate > 0 + ? employee.PayrollProfile.OvertimeHourlyRate + : rate * 1.5m; + var threshold = employee.PayrollProfile?.OvertimeThresholdHours > 0 + ? employee.PayrollProfile.OvertimeThresholdHours + : 40m; + var cumulative = weekHoursBefore.TryGetValue(employee.Id, out var before) ? before : 0m; + + foreach (var shift in dayShifts.OrderBy(s => s.StartTime)) + { + var duration = (decimal)Math.Max(0, (shift.EndTime - shift.StartTime).TotalHours); + var regularHours = Math.Max(0, Math.Min(duration, threshold - cumulative)); + regular += regularHours * rate; + overtime += (duration - regularHours) * overtimeRate; + cumulative += duration; + } + } + + private static decimal CalculateHourlyRate(PayrollProfile? payroll) + { + if (payroll == null) + { + return 0m; + } + + if (string.Equals(payroll.SalaryType, "Monthly", StringComparison.OrdinalIgnoreCase) && payroll.PaymentAmount > 0) + { + return Math.Round(payroll.PaymentAmount / 160m, 2); + } + + return payroll.PaymentAmount; + } + + private static bool HasOvertimeOrMisallocation( + IReadOnlyList shifts, + IReadOnlyDictionary employees) + { + return shifts.Any(s => + (s.EndTime - s.StartTime).TotalHours > 8.0 || + (s.EmployeeId != null && + employees.TryGetValue(s.EmployeeId.Value, out var emp) && + emp.JobRoleId != s.JobRoleId)); + } +} diff --git a/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/TemplateApplicationLoader.cs b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/TemplateApplicationLoader.cs new file mode 100644 index 0000000..ca449bf --- /dev/null +++ b/src/Buy2.Application/Features/Schedules/ApplyTemplate/Services/TemplateApplicationLoader.cs @@ -0,0 +1,167 @@ +using Buy2.Application.Common.Interfaces; +using Buy2.Application.Common.Specifications; +using Buy2.Domain.Entities; + +namespace Buy2.Application.Features.Schedules.ApplyTemplate.Services; + +public sealed class TemplateApplicationLoader : ITemplateApplicationLoader +{ + private readonly IRepository _siteRepository; + private readonly IRepository _templateRepository; + private readonly IRepository _shiftRepository; + private readonly IRepository _employeeRepository; + private readonly IRepository _jobRoleRepository; + private readonly IUnitOfWork _unitOfWork; + + public TemplateApplicationLoader( + IRepository siteRepository, + IRepository templateRepository, + IRepository shiftRepository, + IRepository employeeRepository, + IRepository jobRoleRepository, + IUnitOfWork unitOfWork) + { + _siteRepository = siteRepository; + _templateRepository = templateRepository; + _shiftRepository = shiftRepository; + _employeeRepository = employeeRepository; + _jobRoleRepository = jobRoleRepository; + _unitOfWork = unitOfWork; + } + + public Task LoadSiteAsync(int siteId, CancellationToken cancellationToken = default) + { + return _siteRepository.FirstOrDefaultAsync( + s => s.Id == siteId, cancellationToken, nameof(Site.OperationalHours)); + } + + public Task LoadTemplateAsync(int templateId, CancellationToken cancellationToken = default) + { + return _templateRepository.FirstOrDefaultAsync( + t => t.Id == templateId, cancellationToken, nameof(ShiftTemplate.ShiftBlocks)); + } + + public Task> LoadDayShiftsAsync( + int siteId, DateOnly date, CancellationToken cancellationToken = default) + { + var dayStart = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var dayEnd = dayStart.AddDays(1); + + var specification = new Specification() + .Where(s => s.SiteId == siteId && s.StartTime < dayEnd && s.EndTime > dayStart) + .OrderBy(s => s.StartTime); + + return _shiftRepository.ListAsync(specification, cancellationToken); + } + + public async Task> LoadEmployeesAsync( + List blocks, CancellationToken cancellationToken = default) + { + var ids = blocks + .Where(b => b.EmployeeId.HasValue) + .Select(b => b.EmployeeId!.Value) + .Distinct() + .ToList(); + + if (ids.Count == 0) + { + return new Dictionary(); + } + + var employees = await _employeeRepository.ListAsync( + e => ids.Contains(e.Id), cancellationToken, nameof(Employee.PayrollProfile)); + + return employees.ToDictionary(e => e.Id); + } + + public async Task> LoadRoleTitlesAsync( + List blocks, CancellationToken cancellationToken = default) + { + var ids = blocks.Select(b => b.JobRoleId).Distinct().ToList(); + + var roles = await _jobRoleRepository.ListAsync( + r => ids.Contains(r.Id), cancellationToken); + + return roles.ToDictionary(r => r.Id, r => r.Title); + } + + public async Task> LoadCostEmployeesAsync( + List finalDay, + Dictionary known, + CancellationToken cancellationToken = default) + { + var missingIds = finalDay + .Where(s => s.EmployeeId.HasValue && !known.ContainsKey(s.EmployeeId.Value)) + .Select(s => s.EmployeeId!.Value) + .Distinct() + .ToList(); + + if (missingIds.Count == 0) + { + return new Dictionary(known); + } + + var missing = await _employeeRepository.ListAsync( + e => missingIds.Contains(e.Id), cancellationToken, nameof(Employee.PayrollProfile)); + + foreach (var employee in missing) + { + known[employee.Id] = employee; + } + + return known; + } + + public async Task> LoadWeeklyHoursBeforeDayAsync( + List finalDay, DateOnly date, CancellationToken cancellationToken = default) + { + var ids = finalDay + .Where(s => s.EmployeeId.HasValue) + .Select(s => s.EmployeeId!.Value) + .Distinct() + .ToList(); + + if (ids.Count == 0) + { + return new Dictionary(); + } + + var (weekStart, _) = GetWeekBoundary(date); + var dayStart = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + var shifts = await _shiftRepository.ListAsync( + s => s.EmployeeId != null + && ids.Contains(s.EmployeeId.Value) + && s.StartTime >= weekStart + && s.StartTime < dayStart, + cancellationToken); + + return ids.ToDictionary( + id => id, + id => shifts + .Where(s => s.EmployeeId == id) + .Sum(s => (decimal)(s.EndTime - s.StartTime).TotalHours)); + } + + public async Task PersistAsync(IEnumerable planned, CancellationToken cancellationToken = default) + { + foreach (var block in planned.Where(p => !p.Pruned)) + { + await _shiftRepository.AddAsync(block.Entity, cancellationToken); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + private static (DateTimeOffset WeekStart, DateTimeOffset WeekEnd) GetWeekBoundary(DateOnly targetDate) + { + int diff = (7 + (int)targetDate.DayOfWeek - (int)DayOfWeek.Monday) % 7; + var monday = targetDate.AddDays(-diff); + var sunday = monday.AddDays(6); + + var weekStart = new DateTimeOffset(monday.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var weekEnd = new DateTimeOffset(sunday.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + return (weekStart, weekEnd); + } +} diff --git a/src/Buy2.Infrastructure/Persistence/Repositories/GenericRepository.cs b/src/Buy2.Infrastructure/Persistence/Repositories/GenericRepository.cs index cae918d..65d88ec 100644 --- a/src/Buy2.Infrastructure/Persistence/Repositories/GenericRepository.cs +++ b/src/Buy2.Infrastructure/Persistence/Repositories/GenericRepository.cs @@ -1,5 +1,5 @@ using Buy2.Application.Common.Interfaces; -using Buy2.Domain.Entities; +using Buy2.Application.Common.Specifications; using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; @@ -13,22 +13,116 @@ public GenericRepository(Buy2DbContext context) _context = context; } - public IQueryable Query(bool asNoTracking = true) + public Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default) { - return asNoTracking - ? _context.Set().AsNoTracking() - : _context.Set().AsQueryable(); + return ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken); } - public async Task AddAsync(T entity, CancellationToken cancellationToken = default) => await _context.AddAsync(entity, cancellationToken); + public Task FirstOrDefaultAsync( + Expression> predicate, + CancellationToken cancellationToken = default, + params string[] includes) + { + return ApplySpecification(new Specification().Where(predicate).Include(includes)) + .FirstOrDefaultAsync(cancellationToken); + } - public async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => await _context.AddRangeAsync(entities, cancellationToken); + public Task FirstOrDefaultAsync( + ISpecification specification, + Expression> selector, + CancellationToken cancellationToken = default) + { + return ApplySpecification(specification).Select(selector).FirstOrDefaultAsync(cancellationToken); + } - public void Delete(T entity) => _context.Remove(entity); + public Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + return ApplySpecification(specification).ToListAsync(cancellationToken); + } - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + public Task> ListAsync( + Expression>? predicate = null, + CancellationToken cancellationToken = default, + params string[] includes) { - return await _context.Set().ToListAsync(cancellationToken); + var specification = new Specification().Include(includes); + if (predicate is not null) + { + specification.Where(predicate); + } + + return ApplySpecification(specification).ToListAsync(cancellationToken); + } + + public Task> ListAsync( + ISpecification specification, + Expression> selector, + CancellationToken cancellationToken = default) + { + return ApplySpecification(specification).Select(selector).ToListAsync(cancellationToken); + } + + public Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + { + var query = BaseQuery(tracked: false, ignoreQueryFilters: false); + if (predicate is not null) + { + query = query.Where(predicate); + } + + return query.CountAsync(cancellationToken); + } + + public Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + // Paging and ordering do not affect the total; criteria only. + var query = BaseQuery(tracked: false, ignoreQueryFilters: specification.IgnoreQueryFilters); + if (specification.Criteria is not null) + { + query = query.Where(specification.Criteria); + } + + return query.CountAsync(cancellationToken); + } + + public Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return _context.Set().AnyAsync(predicate, cancellationToken); + } + + public Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) + { + return Filtered(predicate).SumAsync(selector, cancellationToken); + } + + public Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) + { + return Filtered(predicate).SumAsync(selector, cancellationToken); + } + + public Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) + { + return Filtered(predicate).SumAsync(selector, cancellationToken); + } + + public Task SumAsync(Expression>? predicate, Expression> selector, CancellationToken cancellationToken = default) + { + return Filtered(predicate).SumAsync(selector, cancellationToken); + } + + public async Task> PagedAsync(ISpecification specification, int pageNumber, int pageSize, CancellationToken cancellationToken = default) + { + pageNumber = Math.Max(1, pageNumber); + pageSize = Math.Clamp(pageSize, 1, 500); + + var totalCount = await CountAsync(specification, cancellationToken); + + var query = ApplySpecification(specification, applyPaging: false) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize); + + var items = await query.ToListAsync(cancellationToken); + return new PagedResult(items, totalCount, pageNumber, pageSize); } public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) @@ -36,10 +130,94 @@ public async Task> GetAllAsync(CancellationToken cancellationToke return await _context.Set().FindAsync([id], cancellationToken); } - public async Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default) + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - return await _context.Set().AnyAsync(predicate, cancellationToken); + return await _context.Set().ToListAsync(cancellationToken); } + public async Task AddAsync(T entity, CancellationToken cancellationToken = default) => await _context.AddAsync(entity, cancellationToken); + + public async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) => await _context.AddRangeAsync(entities, cancellationToken); + + public void Delete(T entity) => _context.Remove(entity); + public void Update(T entity) => _context.Update(entity); + + // TEMPORARY for the stacked review of SCRUM-389: keeps pre-existing + // Query()-based handlers compiling until the follow-up (SCRUM-390) + // migrates them and removes this member. + public IQueryable Query(bool asNoTracking = true) + { + return BaseQuery(tracked: !asNoTracking, ignoreQueryFilters: false); + } + + private IQueryable Filtered(Expression>? predicate) + { + var query = _context.Set().AsNoTracking(); + return predicate is null ? query : query.Where(predicate); + } + + private IQueryable BaseQuery(bool tracked, bool ignoreQueryFilters) + { + IQueryable query = _context.Set(); + if (!tracked) + { + query = query.AsNoTracking(); + } + + if (ignoreQueryFilters) + { + query = query.IgnoreQueryFilters(); + } + + return query; + } + + private IQueryable ApplySpecification(ISpecification specification, bool applyPaging = true) + { + var query = BaseQuery(specification.Tracked, specification.IgnoreQueryFilters); + + if (specification.Criteria is not null) + { + query = query.Where(specification.Criteria); + } + + foreach (var include in specification.Includes) + { + query = query.Include(include); + } + + var ordered = false; + foreach (var ordering in specification.Orderings) + { + if (!ordered) + { + query = ordering.Descending + ? query.OrderByDescending(ordering.KeySelector) + : query.OrderBy(ordering.KeySelector); + ordered = true; + } + else if (query is IOrderedQueryable orderedQuery) + { + query = ordering.Descending + ? orderedQuery.ThenByDescending(ordering.KeySelector) + : orderedQuery.ThenBy(ordering.KeySelector); + } + } + + if (applyPaging) + { + if (specification.Skip.HasValue) + { + query = query.Skip(specification.Skip.Value); + } + + if (specification.Take.HasValue) + { + query = query.Take(specification.Take.Value); + } + } + + return query; + } } diff --git a/tests/Buy2.Domain.Tests/Schedules/ApplyTemplateTests.cs b/tests/Buy2.Domain.Tests/Schedules/ApplyTemplateTests.cs index 35f7b18..7d65f74 100644 --- a/tests/Buy2.Domain.Tests/Schedules/ApplyTemplateTests.cs +++ b/tests/Buy2.Domain.Tests/Schedules/ApplyTemplateTests.cs @@ -1,5 +1,7 @@ +using Buy2.Application.Common.Interfaces; using Buy2.Application.DTOs.Schedules; using Buy2.Application.Features.Schedules.ApplyTemplate; +using Buy2.Application.Features.Schedules.ApplyTemplate.Services; using Buy2.Domain.Entities; using Buy2.Domain.Enums; using Buy2.Infrastructure.Persistence; @@ -25,17 +27,23 @@ private Buy2DbContext CreateDbContext() private static ApplyTemplateCommandHandler CreateHandler(Buy2DbContext context) { + IRepository sites = new GenericRepository(context); + IRepository templates = new GenericRepository(context); + IRepository shifts = new GenericRepository(context); + IRepository employees = new GenericRepository(context); + IRepository roles = new GenericRepository(context); + IRepository links = new GenericRepository(context); + IRepository hours = new GenericRepository(context); + IRepository requests = new GenericRepository(context); + IRepository attendance = new GenericRepository(context); + IUnitOfWork uow = new UnitOfWork(context); + return new ApplyTemplateCommandHandler( - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new GenericRepository(context), - new UnitOfWork(context)); + new TemplateApplicationLoader(sites, templates, shifts, employees, roles, uow), + new AvailabilityResolver(links, hours, requests, attendance), + new EligibilityEvaluator(), + new OverlapResolver(shifts), + new ScheduleAnalyticsService()); } private static Site SeedSite(Buy2DbContext context)