From 98c53f35921e9132c2cfc1b72f3afc00e9298331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B8ralt?= Date: Mon, 13 Nov 2023 15:29:52 +0100 Subject: [PATCH] working new implementation --- backend/Api/Common/StorageService.cs | 76 +++++++ .../ConsultantController.cs | 35 +++- .../ConsultantExtensions.cs | 26 +-- .../ConsultantReadModel.cs | 2 +- backend/Api/Staffing/ReadModelFactory.cs | 185 ++++++++++++++++++ backend/Core/DomainModels/Consultant.cs | 31 +++ backend/Tests/AbsenceTest.cs | 2 +- 7 files changed, 331 insertions(+), 26 deletions(-) create mode 100644 backend/Api/Common/StorageService.cs rename backend/Api/{Consultants => Staffing}/ConsultantController.cs (86%) rename backend/Api/{Consultants => Staffing}/ConsultantExtensions.cs (81%) rename backend/Api/{Consultants => Staffing}/ConsultantReadModel.cs (98%) create mode 100644 backend/Api/Staffing/ReadModelFactory.cs diff --git a/backend/Api/Common/StorageService.cs b/backend/Api/Common/StorageService.cs new file mode 100644 index 00000000..489c683a --- /dev/null +++ b/backend/Api/Common/StorageService.cs @@ -0,0 +1,76 @@ +using Core.DomainModels; +using Database.DatabaseContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Api.Common; + +public class StorageService +{ + private readonly IMemoryCache _cache; + private readonly ApplicationContext _dbContext; + + public StorageService(IMemoryCache cache, ApplicationContext context) + { + _cache = cache; + _dbContext = context; + } + + public List LoadConsultants(string orgUrlKey) + { + if (_cache.TryGetValue>("asdf", out var consultants)) + if (consultants != null) + return consultants; + + var loadedConsultants = LoadConsultantsFromDb(orgUrlKey); + _cache.Set("asdf", loadedConsultants); + return loadedConsultants; + } + + private List LoadConsultantsFromDb(string orgUrlKey) + { + var consultantList = _dbContext.Consultant + .Include(consultant => consultant.Department) + .ThenInclude(department => department.Organization) + .Where(consultant => consultant.Department.Organization.UrlKey == orgUrlKey) + .OrderBy(consultant => consultant.Name) + .ToList(); + + var staffingPrConsultant = _dbContext.Staffing + .Include(s => s.Consultant) + .Include(staffing => staffing.Project) + .ThenInclude(project => project.Customer) + .GroupBy(staffing => staffing.Consultant.Id) + .ToDictionary(group => group.Key, grouping => grouping.ToList()); + + var plannedAbsencePrConsultant = _dbContext.PlannedAbsence + .Include(absence => absence.Absence) + .Include(absence => absence.Consultant) + .GroupBy(absence => absence.Consultant.Id) + .ToDictionary(grouping => grouping.Key, grouping => grouping.ToList()); + + var vacationsPrConsultant = _dbContext.Vacation + .Include(vacation => vacation.Consultant) + .GroupBy(vacation => vacation.Consultant.Id) + .ToDictionary(grouping => grouping.Key, grouping => grouping.ToList()); + + 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) + ? vacations + : new List(); + + return consultant; + }).ToList(); + + return hydratedConsultants; + } +} \ No newline at end of file diff --git a/backend/Api/Consultants/ConsultantController.cs b/backend/Api/Staffing/ConsultantController.cs similarity index 86% rename from backend/Api/Consultants/ConsultantController.cs rename to backend/Api/Staffing/ConsultantController.cs index 25dfa5e7..82058bcb 100644 --- a/backend/Api/Consultants/ConsultantController.cs +++ b/backend/Api/Staffing/ConsultantController.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using Api.Cache; +using Api.Common; using Core.DomainModels; using Core.Services; using Database.DatabaseContext; @@ -7,7 +9,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace Api.Consultants; +namespace Api.Staffing; [Authorize] [Route("/v0/{orgUrlKey}/consultants")] @@ -31,6 +33,7 @@ public ActionResult> Get( [FromQuery(Name = "WeekSpan")] int numberOfWeeks = 8, [FromQuery(Name = "includeOccupied")] bool includeOccupied = true) { + var timer = Stopwatch.StartNew(); var selectedYear = selectedYearParam ?? DateTime.Now.Year; var selectedWeekNumber = selectedWeekParam ?? DateService.GetWeekNumber(DateTime.Now); var selectedWeek = new Week(selectedYear, selectedWeekNumber); @@ -42,8 +45,24 @@ public ActionResult> Get( || c.IsOccupied ) .ToList(); + var elapsed = timer.ElapsedMilliseconds; + return Ok(new { Time = elapsed, Data = consultants }); + } + + [HttpGet("test")] + public ActionResult GetRefactored() + { + var timer = Stopwatch.StartNew(); + const string orlUrlKey = "variant-norge"; + var selectedYear = DateTime.Now.Year; + var selectedWeekNumber = DateService.GetWeekNumber(DateTime.Now); + var selectedWeek = new Week(selectedYear, selectedWeekNumber); + var weekSet = DateService.GetNextWeeks(selectedWeek, 8); - return Ok(consultants); + var service = new StorageService(_cache, _context); + var readModels = new ReadModelFactory(service).GetConsultantReadModelsForWeeks(orlUrlKey, weekSet); + var elapsed = timer.ElapsedMilliseconds; + return Ok(new { Time = elapsed, Data = readModels }); } private List GetConsultantReadModels(string orgUrlKey, List weeks) @@ -66,9 +85,11 @@ private Dictionary LoadStaffingByProjectTypeForWeeks(L { var firstWeek = weeks.First().ToSortableInt(); var lastWeek = weeks.Last().ToSortableInt(); - + return _context.Staffing - .Where(staffing => firstWeek <= (staffing.Year * 100 + staffing.Week) && (staffing.Year * 100 + staffing.Week) <= lastWeek) //Compare weeks by using the format yyyyww, for example 202352 and 202401 + .Where(staffing => firstWeek <= staffing.Year * 100 + staffing.Week && + staffing.Year * 100 + staffing.Week <= + lastWeek) //Compare weeks by using the format yyyyww, for example 202352 and 202401 .Where(staffing => staffing.Project.State == state) .Include(s => s.Consultant) @@ -83,7 +104,7 @@ private List LoadReadModelFromDb(string orgUrlKey, List LoadReadModelFromDb(string orgUrlKey, List plannedAbsence.Consultant) .Include(plannedAbsence => plannedAbsence.Absence) - .Where(absence => firstWeek <= (absence.Year * 100 + absence.WeekNumber) && (absence.Year * 100 + absence.WeekNumber) <= lastWeek) //Compare weeks by using the format yyyyww, for example 202352 and 202401 + .Where(absence => firstWeek <= absence.Year * 100 + absence.WeekNumber && + absence.Year * 100 + absence.WeekNumber <= + lastWeek) //Compare weeks by using the format yyyyww, for example 202352 and 202401 .GroupBy(plannedAbsence => new StaffingGroupKey(plannedAbsence.Consultant.Id, plannedAbsence.Absence.Id, plannedAbsence.Year, plannedAbsence.WeekNumber)) diff --git a/backend/Api/Consultants/ConsultantExtensions.cs b/backend/Api/Staffing/ConsultantExtensions.cs similarity index 81% rename from backend/Api/Consultants/ConsultantExtensions.cs rename to backend/Api/Staffing/ConsultantExtensions.cs index 04f2e422..29a37c24 100644 --- a/backend/Api/Consultants/ConsultantExtensions.cs +++ b/backend/Api/Staffing/ConsultantExtensions.cs @@ -2,7 +2,7 @@ using Core.DomainModels; using Core.Services; -namespace Api.Consultants; +namespace Api.Staffing; public static class ConsultantExtensions { @@ -37,33 +37,23 @@ public static ConsultantReadModel MapToReadModelList( isOccupied); } - public static WeeklyBookingReadModel GetBookingModelForWeek(this Consultant consultant, int year, int week) + public static WeeklyBookingReadModel GetBookingModelForWeek(this Consultant consultant, int year, int weekNumber) { var org = consultant.Department.Organization; var hoursPrWorkDay = org.HoursPerWorkday; - var holidayHours = org.GetTotalHolidaysOfWeek(new Week(year, week)) * hoursPrWorkDay; - var vacationHours = consultant.Vacations.Count(v => DateService.DateIsInWeek(v.Date, new Week(year, week))) * - hoursPrWorkDay; + var week = new Week(year, weekNumber); - var plannedAbsenceHours = consultant.PlannedAbsences - .Where(pa => pa.Year == year && pa.WeekNumber == week) - .Select(pa => pa.Hours) - .Sum(); - - var billableHours = consultant.Staffings - .Where(s => s.Year == year && s.Week == week && s.Project.State.Equals(ProjectState.Active)) - .Select(s => s.Hours).Sum(); - - var offeredHours = consultant.Staffings - .Where(s => s.Year == year && s.Week == week && s.Project.State.Equals(ProjectState.Offer)) - .Select(s => s.Hours).Sum(); + var holidayHours = org.GetTotalHolidayHoursOfWeek(week); + var vacationHours = consultant.GetVacationHoursForWeek(week); + var plannedAbsenceHours = consultant.GetAbsenceHoursForWeek(week); + var billableHours = consultant.GetBillableHoursForWeek(week); + var offeredHours = consultant.GetOfferedHoursForWeek(week); var bookedTime = billableHours + plannedAbsenceHours + vacationHours + holidayHours; - var totalFreeTime = Math.Max(hoursPrWorkDay * 5 - bookedTime, 0); diff --git a/backend/Api/Consultants/ConsultantReadModel.cs b/backend/Api/Staffing/ConsultantReadModel.cs similarity index 98% rename from backend/Api/Consultants/ConsultantReadModel.cs rename to backend/Api/Staffing/ConsultantReadModel.cs index 837f6682..573136c3 100644 --- a/backend/Api/Consultants/ConsultantReadModel.cs +++ b/backend/Api/Staffing/ConsultantReadModel.cs @@ -1,6 +1,6 @@ using Core.DomainModels; -namespace Api.Consultants; +namespace Api.Staffing; public record ConsultantReadModel(int Id, string Name, string Email, List Competences, string Department, int YearsOfExperience, Degree Degree, diff --git a/backend/Api/Staffing/ReadModelFactory.cs b/backend/Api/Staffing/ReadModelFactory.cs new file mode 100644 index 00000000..bf615d4d --- /dev/null +++ b/backend/Api/Staffing/ReadModelFactory.cs @@ -0,0 +1,185 @@ +using Api.Common; +using Api.Organisation; +using Core.DomainModels; +using Core.Services; + +namespace Api.Staffing; + +public class ReadModelFactory +{ + private readonly StorageService _storageService; + + public ReadModelFactory(StorageService storageService) + { + _storageService = storageService; + } + + public List GetConsultantReadModelsForWeeks(string orgUrlKey, List weeks) + { + var firstDayInScope = DateService.FirstDayOfWorkWeek(weeks.First()); + var firstWorkDayOutOfScope = DateService.LastWorkDayOfWeek(weeks.Last()).AddDays(1); + + return _storageService.LoadConsultants(orgUrlKey) + .Where(c => c.EndDate == null || c.EndDate > firstDayInScope) + .Where(c => c.StartDate == null || c.StartDate <= firstWorkDayOutOfScope) + .Select(consultant => MapToReadModelList(consultant, weeks)) + .ToList(); + } + + private static ConsultantReadModel MapToReadModelList( + Consultant consultant, + List weekSet) + { + weekSet.Sort(); + + var detailedBookings = DetailedBookings(consultant, weekSet); + + var hoursPrWorkday = consultant.Department.Organization.HoursPerWorkday; + var hoursPrWeek = hoursPrWorkday * 5; + + + var bookingSummary = weekSet.Select(week => + GetBookedHours(week, detailedBookings, consultant) + ).ToList(); + + //isOccupied should not include offered or sellable time, as it's sometimes necessary to "double-book" + var isOccupied = bookingSummary.All(b => + b.BookingModel.TotalBillable + b.BookingModel.TotalPlannedAbstences + b.BookingModel.TotalVacationHours + + b.BookingModel.TotalHolidayHours >= hoursPrWeek); + + return new ConsultantReadModel( + consultant.Id, consultant.Name, consultant.Email, + consultant.Competences.Select(competence => competence.Name).ToList(), + consultant.Department.Name, + consultant.YearsOfExperience, + consultant.Degree ?? Degree.Master, + bookingSummary, + detailedBookings.ToList(), + isOccupied); + } + + + /// + /// Takes in many data points collected from the DB, and joins them into a set of DetailedBookings + /// for a given consultant and set of weeks + /// + private static List DetailedBookings(Consultant consultant, + List weekSet) + { + weekSet.Sort(); + + // var billableProjects = UniqueWorkTypes(projects, billableStaffing); + var billableBookings = consultant.Staffings + .Where(staffing => staffing.Project.State == ProjectState.Active) + .Where(staffing => weekSet.Contains(new Week(staffing.Year, staffing.Week))) + .GroupBy(staffing => staffing.Project.Customer.Name) + .Select(grouping => new DetailedBooking(new BookingDetails(grouping.Key, BookingType.Booking), + weekSet.Select(week => new WeeklyHours( + week.ToSortableInt(), grouping + .Where(staffing => + new Week(staffing.Year, staffing.Week).ToSortableInt() == week.ToSortableInt()) + .Sum(staffing => staffing.Hours))).ToList() + )); + + var offeredBookings = consultant.Staffings + .Where(staffing => staffing.Project.State == ProjectState.Offer) + .Where(staffing => weekSet.Contains(new Week(staffing.Year, staffing.Week))) + .GroupBy(staffing => staffing.Project.Customer.Name) + .Select(grouping => new DetailedBooking(new BookingDetails(grouping.Key, BookingType.Offer), + weekSet.Select(week => new WeeklyHours( + week.ToSortableInt(), grouping + .Where(staffing => + new Week(staffing.Year, staffing.Week).ToSortableInt() == week.ToSortableInt()) + .Sum(staffing => staffing.Hours))).ToList() + )); + + var plannedAbsencesPrWeek = consultant.PlannedAbsences + .Where(absence => weekSet.Contains(new Week(absence.Year, absence.WeekNumber))) + .GroupBy(absence => absence.Absence.Name) + .Select(grouping => new DetailedBooking( + new BookingDetails(grouping.Key, BookingType.PlannedAbsence), + weekSet.Select(week => new WeeklyHours( + week.ToSortableInt(), + grouping + .Where(absence => + new Week(absence.Year, absence.WeekNumber).ToSortableInt() == week.ToSortableInt()) + .Sum(absence => absence.Hours) + )).ToList() + )); + + + var detailedBookings = billableBookings.Concat(offeredBookings).Concat(plannedAbsencesPrWeek); + + var vacationsInSet = + consultant.Vacations.Where(v => weekSet.Any(week => DateService.DateIsInWeek(v.Date, week))) + .ToList(); + + if (vacationsInSet.Count > 0) + { + var vacationsPrWeek = weekSet.Select(week => new WeeklyHours( + week.ToSortableInt(), + vacationsInSet.Count(vacation => DateService.DateIsInWeek(vacation.Date, week)) * + consultant.Department.Organization.HoursPerWorkday + )).ToList(); + detailedBookings = detailedBookings.Append(new DetailedBooking( + new BookingDetails("Ferie", BookingType.Vacation), + vacationsPrWeek)); + } + + var detailedBookingList = detailedBookings.ToList(); + + // Remove empty rows + detailedBookingList.RemoveAll(detailedBooking => detailedBooking.Hours.Sum(hours => hours.Hours) == 0); + + return detailedBookingList; + } + + private static BookedHoursPerWeek GetBookedHours(Week week, IEnumerable detailedBookings, + Consultant consultant) + { + var totalHolidayHours = consultant.Department.Organization.GetTotalHolidayHoursOfWeek(week); + + var detailedBookingsArray = detailedBookings as DetailedBooking[] ?? detailedBookings.ToArray(); + var totalBillable = + DetailedBooking.GetTotalHoursPrBookingTypeAndWeek(detailedBookingsArray, BookingType.Booking, + week); + + var totalOffered = DetailedBooking.GetTotalHoursPrBookingTypeAndWeek(detailedBookingsArray, + BookingType.Offer, + week); + + var totalAbsence = DetailedBooking.GetTotalHoursPrBookingTypeAndWeek(detailedBookingsArray, + BookingType.PlannedAbsence, + week); + + var totalVacations = DetailedBooking.GetTotalHoursPrBookingTypeAndWeek(detailedBookingsArray, + BookingType.Vacation, + week); + + var bookedTime = totalBillable + totalAbsence + totalVacations + totalHolidayHours; + var hoursPrWorkDay = consultant.Department.Organization.HoursPerWorkday; + + var totalFreeTime = + Math.Max(hoursPrWorkDay * 5 - bookedTime, 0); + + var totalOverbooked = + Math.Max(bookedTime - hoursPrWorkDay * 5, 0); + + return new BookedHoursPerWeek( + week.Year, + week.WeekNumber, + week.ToSortableInt(), + GetDatesForWeek(week), + new WeeklyBookingReadModel(totalBillable, totalOffered, totalAbsence, totalFreeTime, + totalHolidayHours, totalVacations, + totalOverbooked) + ); + } + + private static string GetDatesForWeek(Week week) + { + return DateService.GetDatesInWorkWeek(week.Year, week.WeekNumber)[0].ToString("dd.MM") + + " - " + DateService + .GetDatesInWorkWeek(week.Year, week.WeekNumber)[^1].ToString("dd.MM"); + } +} \ No newline at end of file diff --git a/backend/Core/DomainModels/Consultant.cs b/backend/Core/DomainModels/Consultant.cs index 1239d016..299ddf8d 100644 --- a/backend/Core/DomainModels/Consultant.cs +++ b/backend/Core/DomainModels/Consultant.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using Core.Services; namespace Core.DomainModels; @@ -38,6 +39,36 @@ public int YearsOfExperience return currentAcademicYear - GraduationYear ?? currentAcademicYear; } } + + public double HoursPrWorkDay => Department.Organization.HoursPerWorkday; + + public double GetVacationHoursForWeek(Week week) + { + return Vacations.Count(v => DateService.DateIsInWeek(v.Date, week)) * + HoursPrWorkDay; + } + + public double GetAbsenceHoursForWeek(Week week) + { + return PlannedAbsences + .Where(pa => pa.Year == week.Year && pa.WeekNumber == week.WeekNumber) + .Select(pa => pa.Hours) + .Sum(); + } + + public double GetBillableHoursForWeek(Week week) + { + return Staffings + .Where(s => s.Year == week.Year && s.Week == week.WeekNumber && s.Project.State.Equals(ProjectState.Active)) + .Select(s => s.Hours).Sum(); + } + + public double GetOfferedHoursForWeek(Week week) + { + return Staffings + .Where(s => s.Year == week.Year && s.Week == week.WeekNumber && s.Project.State.Equals(ProjectState.Offer)) + .Select(s => s.Hours).Sum(); + } } public class Competence diff --git a/backend/Tests/AbsenceTest.cs b/backend/Tests/AbsenceTest.cs index 261af064..a5bbe238 100644 --- a/backend/Tests/AbsenceTest.cs +++ b/backend/Tests/AbsenceTest.cs @@ -1,4 +1,4 @@ -using Api.Consultants; +using Api.Staffing; using Core.DomainModels; using Core.Services; using NSubstitute;