From 37b315cd8fac52b6502e52b4d677a1bfb10b9cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B8ralt?= <61310258+jonasbjoralt@users.noreply.github.com> Date: Fri, 11 Oct 2024 09:28:57 +0200 Subject: [PATCH] Add Repository for Staffing (#530) --- backend/Api/Common/StorageService.cs | 95 +----------- .../StaffingController/ReadModelFactory.cs | 4 +- .../StaffingController/StaffingController.cs | 139 ++++++++++++++++-- backend/Core/Staffings/IStaffingRepository.cs | 13 ++ .../Repositories/RepositoryExtensions.cs | 5 + .../Staffings/StaffingCacheRepository.cs | 75 ++++++++++ .../Staffings/StaffingDbRepository.cs | 54 +++++++ 7 files changed, 276 insertions(+), 109 deletions(-) create mode 100644 backend/Core/Staffings/IStaffingRepository.cs create mode 100644 backend/Infrastructure/Repositories/Staffings/StaffingCacheRepository.cs create mode 100644 backend/Infrastructure/Repositories/Staffings/StaffingDbRepository.cs diff --git a/backend/Api/Common/StorageService.cs b/backend/Api/Common/StorageService.cs index 0ea036eb..f1d6f8d6 100644 --- a/backend/Api/Common/StorageService.cs +++ b/backend/Api/Common/StorageService.cs @@ -100,12 +100,6 @@ private List LoadConsultantsFromDb(string orgUrlKey) .OrderBy(consultant => consultant.Name) .ToList(); - var staffingPrConsultant = _dbContext.Staffing - .Include(s => s.Consultant) - .Include(staffing => staffing.Engagement) - .ThenInclude(project => project.Customer) - .GroupBy(staffing => staffing.Consultant.Id) - .ToDictionary(group => group.Key, grouping => grouping.ToList()); var plannedAbsencePrConsultant = _dbContext.PlannedAbsence .Include(absence => absence.Absence) @@ -120,16 +114,12 @@ private List LoadConsultantsFromDb(string orgUrlKey) var hydratedConsultants = consultantList.Select(consultant => { - consultant.Staffings = staffingPrConsultant.TryGetValue(consultant.Id, out var staffing) - ? staffing - : new List(); - consultant.PlannedAbsences = plannedAbsencePrConsultant.TryGetValue(consultant.Id, out var plannedAbsences) ? plannedAbsences : new List(); - consultant.Vacations = vacationsPrConsultant.TryGetValue(consultant.Id, out var vacations) + consultant.Vacations = vacationsPrConsultant.TryGetValue(consultant.Id, out List? vacations) ? vacations : new List(); @@ -139,23 +129,6 @@ private List LoadConsultantsFromDb(string orgUrlKey) return hydratedConsultants; } - private Staffing CreateStaffing(StaffingKey staffingKey, double hours) - { - var consultant = _dbContext.Consultant.Single(c => c.Id == staffingKey.ConsultantId); - var project = _dbContext.Project.Single(p => p.Id == staffingKey.EngagementId); - - var staffing = new Staffing - { - EngagementId = staffingKey.EngagementId, - Engagement = project, - ConsultantId = staffingKey.ConsultantId, - Consultant = consultant, - Hours = hours, - Week = staffingKey.Week - }; - return staffing; - } - private PlannedAbsence CreateAbsence(PlannedAbsenceKey plannedAbsenceKey, double hours) { var consultant = _dbContext.Consultant.Single(c => c.Id == plannedAbsenceKey.ConsultantId); @@ -173,23 +146,6 @@ private PlannedAbsence CreateAbsence(PlannedAbsenceKey plannedAbsenceKey, double return plannedAbsence; } - public void UpdateOrCreateStaffing(StaffingKey staffingKey, double hours, string orgUrlKey) - { - var staffing = _dbContext.Staffing - .FirstOrDefault(s => s.EngagementId.Equals(staffingKey.EngagementId) - && s.ConsultantId.Equals(staffingKey.ConsultantId) - && s.Week.Equals(staffingKey.Week)); - - - if (staffing is null) - _dbContext.Add(CreateStaffing(staffingKey, hours)); - else - staffing.Hours = hours; - - _dbContext.SaveChanges(); - ClearConsultantCache(orgUrlKey); - } - public void UpdateOrCreatePlannedAbsence(PlannedAbsenceKey plannedAbsenceKey, double hours, string orgUrlKey) { @@ -207,55 +163,6 @@ public void UpdateOrCreatePlannedAbsence(PlannedAbsenceKey plannedAbsenceKey, do ClearConsultantCache(orgUrlKey); } - public void UpdateOrCreateStaffings(int consultantId, int projectId, List weeks, double hours, - string orgUrlKey) - { - var consultant = _dbContext.Consultant.Single(c => c.Id == consultantId); - var project = _dbContext.Project.Single(p => p.Id == projectId); - - var org = _dbContext.Organization.FirstOrDefault(o => o.UrlKey == orgUrlKey); - - foreach (var week in weeks) - { - var newHours = hours; - if (org != null) - { - var holidayHours = org.GetTotalHolidayHoursOfWeek(week); - var vacations = _dbContext.Vacation.Where(v => v.ConsultantId.Equals(consultantId)).ToList(); - var vacationHours = vacations.Count(v => week.ContainsDate(v.Date)) * org.HoursPerWorkday; - var plannedAbsenceHours = _dbContext.PlannedAbsence - .Where(pa => pa.Week.Equals(week) && pa.ConsultantId.Equals(consultantId)) - .Select(pa => pa.Hours).Sum(); - - var total = holidayHours + vacationHours + plannedAbsenceHours; - - newHours = hours + total > org.HoursPerWorkday * 5 - ? Math.Max(org.HoursPerWorkday * 5 - total, 0) - : hours; - } - - var staffing = _dbContext.Staffing - .FirstOrDefault(s => s.EngagementId.Equals(projectId) - && s.ConsultantId.Equals(consultantId) - && s.Week.Equals(week)); - if (staffing is null) - _dbContext.Add(new Staffing - { - EngagementId = projectId, - Engagement = project, - ConsultantId = consultantId, - Consultant = consultant, - Hours = newHours, - Week = week - }); - else - staffing.Hours = newHours; - } - - _dbContext.SaveChanges(); - ClearConsultantCache(orgUrlKey); - } - public void UpdateOrCreatePlannedAbsences(int consultantId, int absenceId, List weeks, double hours, string orgUrlKey) diff --git a/backend/Api/StaffingController/ReadModelFactory.cs b/backend/Api/StaffingController/ReadModelFactory.cs index 6d8de1df..252f63c7 100644 --- a/backend/Api/StaffingController/ReadModelFactory.cs +++ b/backend/Api/StaffingController/ReadModelFactory.cs @@ -14,12 +14,12 @@ public ReadModelFactory(StorageService storageService) _storageService = storageService; } - public List GetConsultantReadModelsForWeeks(string orgUrlKey, List weeks) + public List GetConsultantReadModelsForWeeks(List consultants, List weeks) { var firstDayInScope = weeks.First().FirstDayOfWorkWeek(); var firstWorkDayOutOfScope = weeks.Last().LastWorkDayOfWeek(); - return _storageService.LoadConsultants(orgUrlKey) + return consultants .Where(c => c.EndDate == null || c.EndDate > firstDayInScope) .Where(c => c.StartDate == null || c.StartDate < firstWorkDayOutOfScope) .Select(consultant => MapToReadModelList(consultant, weeks)) diff --git a/backend/Api/StaffingController/StaffingController.cs b/backend/Api/StaffingController/StaffingController.cs index 4cd43fce..0b4d937a 100644 --- a/backend/Api/StaffingController/StaffingController.cs +++ b/backend/Api/StaffingController/StaffingController.cs @@ -1,9 +1,11 @@ using Api.Common; +using Core.Consultants; using Core.DomainModels; using Core.PlannedAbsences; using Core.Staffings; using Infrastructure.DatabaseContext; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -12,11 +14,13 @@ namespace Api.StaffingController; [Authorize] [Route("/v0/{orgUrlKey}/staffings")] [ApiController] -public class StaffingController(ApplicationContext context, IMemoryCache cache) : ControllerBase +public class StaffingController(ApplicationContext context, IMemoryCache cache, IStaffingRepository staffingRepository) + : ControllerBase { [HttpGet] - public ActionResult> Get( + public async Task>> Get( [FromRoute] string orgUrlKey, + CancellationToken ct, [FromQuery(Name = "Year")] int? selectedYearParam = null, [FromQuery(Name = "Week")] int? selectedWeekParam = null, [FromQuery(Name = "WeekSpan")] int numberOfWeeks = 8, @@ -29,8 +33,13 @@ public ActionResult> Get( var weekSet = selectedWeek.GetNextWeeks(numberOfWeeks); var service = new StorageService(cache, context); - var readModels = new ReadModelFactory(service).GetConsultantReadModelsForWeeks(orgUrlKey, weekSet); - return Ok(readModels); + var consultants = service.LoadConsultants(orgUrlKey); + consultants = await AddRelationalDataToConsultant(consultants, ct); + + var readModels = new ReadModelFactory(service) + .GetConsultantReadModelsForWeeks(consultants, weekSet); + + return TypedResults.Ok(readModels); } [HttpGet] @@ -80,9 +89,10 @@ public ActionResult> GetConsultantsInProject( [HttpPut] [Route("update")] - public ActionResult Put( + public async Task> Put( [FromRoute] string orgUrlKey, - [FromBody] StaffingWriteModel staffingWriteModel + [FromBody] StaffingWriteModel staffingWriteModel, + CancellationToken ct ) { var service = new StorageService(cache, context); @@ -96,10 +106,14 @@ [FromBody] StaffingWriteModel staffingWriteModel { case BookingType.Booking: case BookingType.Offer: - service.UpdateOrCreateStaffing( - new StaffingKey(staffingWriteModel.EngagementId, staffingWriteModel.ConsultantId, selectedWeek), - staffingWriteModel.Hours, orgUrlKey - ); + var updatedStaffing = CreateStaffing( + new StaffingKey(staffingWriteModel.EngagementId, + staffingWriteModel.ConsultantId, selectedWeek), staffingWriteModel.Hours); + + await staffingRepository.UpsertStaffing(updatedStaffing, ct); + + //TODO: Remove this once repositories for planned absence and vacations are done too + service.ClearConsultantCache(orgUrlKey); break; case BookingType.PlannedAbsence: service.UpdateOrCreatePlannedAbsence( @@ -125,9 +139,10 @@ [FromBody] StaffingWriteModel staffingWriteModel [HttpPut] [Route("update/several")] - public ActionResult Put( + public async Task> Put( [FromRoute] string orgUrlKey, - [FromBody] SeveralStaffingWriteModel severalStaffingWriteModel + [FromBody] SeveralStaffingWriteModel severalStaffingWriteModel, + CancellationToken ct ) { var service = new StorageService(cache, context); @@ -147,8 +162,13 @@ [FromBody] SeveralStaffingWriteModel severalStaffingWriteModel { case BookingType.Booking: case BookingType.Offer: - service.UpdateOrCreateStaffings(severalStaffingWriteModel.ConsultantId, + var updatedStaffings = UpsertMultipleStaffings(severalStaffingWriteModel.ConsultantId, severalStaffingWriteModel.EngagementId, weekSet, severalStaffingWriteModel.Hours, orgUrlKey); + + await staffingRepository.UpsertMultipleStaffings(updatedStaffings, ct); + + //TODO: Remove this once repositories for planned absence and vacations are done too + service.ClearConsultantCache(orgUrlKey); break; case BookingType.PlannedAbsence: service.UpdateOrCreatePlannedAbsences(severalStaffingWriteModel.ConsultantId, @@ -170,6 +190,99 @@ [FromBody] SeveralStaffingWriteModel severalStaffingWriteModel return new ReadModelFactory(service).GetConsultantReadModelForWeeks( severalStaffingWriteModel.ConsultantId, weekSet); } + + // TODO: Move this to a future application layer. This is to consolidate data from various repositories such as Staffing or PlannedAbsence + private async Task> AddRelationalDataToConsultant(List consultants, + CancellationToken ct) + { + var consultantStaffings = + await staffingRepository.GetStaffingForConsultants(consultants.Select(c => c.Id).ToList(), ct); + return consultants.Select(c => + { + var hasStaffing = consultantStaffings.TryGetValue(c.Id, out var staffings); + if (!hasStaffing || staffings is null) + staffings = new List(); + c.Staffings = staffings; + return c; + }).ToList(); + } + + //TODO: Divide this more neatly into various functions for readability. + // This is skipped for now to avoid massive scope-creep. Comments are added for a temporary readability-buff + private List UpsertMultipleStaffings(int consultantId, int engagementId, + List weeks, + double hours, + string orgUrlKey) + { + // Get base data we need. + var consultant = context.Consultant.Single(c => c.Id == consultantId); + var project = context.Project.Single(p => p.Id == engagementId); + + var org = context.Organization.FirstOrDefault(o => o.UrlKey == orgUrlKey); + + // Create one new staffing for each week + var staffingsToUpsert = weeks.Select(week => + { + // This is a variable as we may change it to adapt to maximum possible booking + var newHours = hours; + if (org != null) + { + // Calculates the max hours we can add without overbooking due to vacations, absences, etc + var holidayHours = org.GetTotalHolidayHoursOfWeek(week); + var vacations = context.Vacation.Where(v => v.ConsultantId.Equals(consultantId)).ToList(); + var vacationHours = vacations.Count(v => week.ContainsDate(v.Date)) * org.HoursPerWorkday; + var plannedAbsenceHours = context.PlannedAbsence + .Where(pa => pa.Week.Equals(week) && pa.ConsultantId.Equals(consultantId)) + .Select(pa => pa.Hours).Sum(); + + var total = holidayHours + vacationHours + plannedAbsenceHours; + + newHours = hours + total > org.HoursPerWorkday * 5 + ? Math.Max(org.HoursPerWorkday * 5 - total, 0) + : hours; + } + + + var staffing = context.Staffing + .FirstOrDefault(s => s.EngagementId.Equals(engagementId) + && s.ConsultantId.Equals(consultantId) + && s.Week.Equals(week)); + + if (staffing is null) + return new Staffing + { + EngagementId = engagementId, + Engagement = project, + ConsultantId = consultantId, + Consultant = consultant, + Hours = newHours, + Week = week + }; + + // Set it again in case it was found in query above + staffing.Hours = newHours; + return staffing; + }).ToList(); + + return staffingsToUpsert; + } + + private Staffing CreateStaffing(StaffingKey staffingKey, double hours) + { + // TODO; Rewrite this to not query relations + var consultant = context.Consultant.Single(c => c.Id == staffingKey.ConsultantId); + var project = context.Project.Single(p => p.Id == staffingKey.EngagementId); + + return new Staffing + { + EngagementId = staffingKey.EngagementId, + Engagement = project, + ConsultantId = staffingKey.ConsultantId, + Consultant = consultant, + Hours = hours, + Week = staffingKey.Week + }; + } } public record StaffingWriteModel( diff --git a/backend/Core/Staffings/IStaffingRepository.cs b/backend/Core/Staffings/IStaffingRepository.cs new file mode 100644 index 00000000..7f228ccc --- /dev/null +++ b/backend/Core/Staffings/IStaffingRepository.cs @@ -0,0 +1,13 @@ +namespace Core.Staffings; + +public interface IStaffingRepository +{ + public Task>> GetStaffingForConsultants(List consultantIds, + CancellationToken ct); + + public Task> GetStaffingForConsultant(int consultantId, CancellationToken ct); + + public Task UpsertStaffing(Staffing staffing, CancellationToken ct); + + public Task UpsertMultipleStaffings(List staffings, CancellationToken ct); +} \ No newline at end of file diff --git a/backend/Infrastructure/Repositories/RepositoryExtensions.cs b/backend/Infrastructure/Repositories/RepositoryExtensions.cs index 77a8e306..a2ddd9be 100644 --- a/backend/Infrastructure/Repositories/RepositoryExtensions.cs +++ b/backend/Infrastructure/Repositories/RepositoryExtensions.cs @@ -1,9 +1,11 @@ using Core.Consultants; using Core.Engagements; using Core.Organizations; +using Core.Staffings; using Infrastructure.Repositories.Consultants; using Infrastructure.Repositories.Engagement; using Infrastructure.Repositories.Organization; +using Infrastructure.Repositories.Staffings; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +21,9 @@ public static void AddRepositories(this WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.Decorate(); + builder.Services.AddScoped(); } } \ No newline at end of file diff --git a/backend/Infrastructure/Repositories/Staffings/StaffingCacheRepository.cs b/backend/Infrastructure/Repositories/Staffings/StaffingCacheRepository.cs new file mode 100644 index 00000000..34971e9c --- /dev/null +++ b/backend/Infrastructure/Repositories/Staffings/StaffingCacheRepository.cs @@ -0,0 +1,75 @@ +using Core.Staffings; +using Microsoft.Extensions.Caching.Memory; + +namespace Infrastructure.Repositories.Staffings; + +public class StaffingCacheRepository(IStaffingRepository sourceRepository, IMemoryCache cache) : IStaffingRepository +{ + public async Task>> GetStaffingForConsultants(List consultantIds, + CancellationToken ct) + { + var nonCachedIds = new List(); + var result = new Dictionary>(); + + foreach (var consultantId in consultantIds.Distinct()) + { + var staffingList = GetStaffingsFromCache(consultantId); + if (staffingList is null) + nonCachedIds.Add(consultantId); + else + result.Add(consultantId, staffingList); + } + + var queriedStaffingLists = await sourceRepository.GetStaffingForConsultants(nonCachedIds, ct); + foreach (var (cId, staffings) in queriedStaffingLists) + { + result.Add(cId, staffings); + cache.Set(StaffingCacheKey(cId), staffings); + } + + return result; + } + + public async Task> GetStaffingForConsultant(int consultantId, CancellationToken ct) + { + var staffingList = GetStaffingsFromCache(consultantId); + if (staffingList is not null) return staffingList; + + staffingList = await sourceRepository.GetStaffingForConsultant(consultantId, ct); + cache.Set(StaffingCacheKey(consultantId), staffingList); + return staffingList; + } + + public async Task UpsertStaffing(Staffing staffing, CancellationToken ct) + { + await sourceRepository.UpsertStaffing(staffing, ct); + ClearStaffingCache(staffing.ConsultantId); + } + + public async Task UpsertMultipleStaffings(List staffings, CancellationToken ct) + { + await sourceRepository.UpsertMultipleStaffings(staffings, ct); + + var consultantIds = staffings.Select(staffing => staffing.ConsultantId).Distinct(); + foreach (var consultantId in consultantIds) ClearStaffingCache(consultantId); + } + + private List? GetStaffingsFromCache(int consultantId) + { + if (cache.TryGetValue>(StaffingCacheKey(consultantId), out var staffingList)) + if (staffingList is not null) + return staffingList; + + return null; + } + + private void ClearStaffingCache(int consultantId) + { + cache.Remove(StaffingCacheKey(consultantId)); + } + + private static string StaffingCacheKey(int consultantId) + { + return $"StaffingCacheRepository/{consultantId}"; + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Repositories/Staffings/StaffingDbRepository.cs b/backend/Infrastructure/Repositories/Staffings/StaffingDbRepository.cs new file mode 100644 index 00000000..55bb824a --- /dev/null +++ b/backend/Infrastructure/Repositories/Staffings/StaffingDbRepository.cs @@ -0,0 +1,54 @@ +using Core.Staffings; +using Infrastructure.DatabaseContext; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Repositories.Staffings; + +public class StaffingDbRepository(ApplicationContext context) : IStaffingRepository +{ + public async Task>> GetStaffingForConsultants(List consultantIds, + CancellationToken ct) + { + var ids = consultantIds.ToArray(); + + return await context.Staffing + .Where(s => ids.Contains(s.ConsultantId)) + .Include(s => s.Consultant) + .Include(staffing => staffing.Engagement) + .ThenInclude(project => project.Customer) + .GroupBy(staffing => staffing.Consultant.Id) + .ToDictionaryAsync(group => group.Key, grouping => grouping.ToList(), ct); + } + + public async Task> GetStaffingForConsultant(int consultantId, CancellationToken ct) + { + return await context.Staffing + .Where(staffing => staffing.ConsultantId == consultantId) + .Include(s => s.Engagement) + .ThenInclude(p => p.Customer) + .ToListAsync(ct); + } + + + public async Task UpsertMultipleStaffings(List staffings, CancellationToken ct) + { + foreach (var staffing in staffings) await UpsertStaffing(staffing, ct); + } + + public async Task UpsertStaffing(Staffing staffing, CancellationToken ct) + { + var existingStaffing = context.Staffing + .FirstOrDefault(s => s.EngagementId.Equals(staffing.EngagementId) + && s.ConsultantId.Equals(staffing.ConsultantId) + && s.Week.Equals(staffing.Week)); + + if (existingStaffing is null) + { + await context.Staffing.AddAsync(staffing, ct); + return; + } + + existingStaffing.Hours = staffing.Hours; + await context.SaveChangesAsync(ct); + } +} \ No newline at end of file