From 196c900eca2f1d3e09711ef9ae074ba57ba89dac Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:18:38 +0100 Subject: [PATCH] feat(summary): AZ Func weekly task owner report worker (#714) - [x] New feature - [ ] Bug fix - [ ] High impact **Description of work:** AB57451 Similar to resource owners report: - This worker retrieves relevant data to create the report metrics and then stores it in summary api **Testing:** - [x] Can be tested - [x] Automatic tests created / updated - [x] Local tests are passing Written tests for the report mertrics. Done simple tests for 2 projects **Checklist:** - [x] Considered automated tests - [x] Considered updating specification / documentation - [x] Considered work items - [x] Considered security - [x] Performed developer testing - [x] Checklist finalized / ready for review --- .../ApiClients/IOrgApiClient.cs | 1 + .../ApiClients/IPeopleApiClient.cs | 7 +- .../ApiClients/IResourcesApiClient.cs | 2 + .../ApiClients/ISummaryApiClient.cs | 50 ++- .../ApiClients/OrgApiClient.cs | 6 + .../ApiClients/PeopleApiClient.cs | 15 +- .../ApiClients/ResourcesApiClient.cs | 18 +- .../ApiClients/SummaryApiClient.cs | 9 + .../Fusion.Resources.Functions.Common.csproj | 1 + .../Integration/Http/HttpClientExtensions.cs | 20 +- .../ResourceOwnerReportsController.cs | 2 +- .../WeeklyTaskOwnerReportWorker.cs | 148 +++++++ .../WeeklyDepartmentSummarySender.cs | 2 +- .../WeeklyTaskOwnerReportDataCreator.cs | 180 +++++++++ .../WeeklyTaskOwnerReportDataCreator.cs | 373 ++++++++++++++++++ 15 files changed, 822 insertions(+), 12 deletions(-) create mode 100644 src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportWorker.cs create mode 100644 src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs create mode 100644 src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IOrgApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IOrgApiClient.cs index c151e7a4d..560e08f35 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/IOrgApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IOrgApiClient.cs @@ -9,6 +9,7 @@ public interface IOrgClient Task GetChangeLog(string projectId, DateTime timestamp); Task> GetProjectsAsync(ODataQuery? query = null, CancellationToken cancellationToken = default); + Task> GetProjectPositions(string projectId, CancellationToken cancellationToken = default); } #region model diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IPeopleApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IPeopleApiClient.cs index 9b7ff8d4a..628e63d2c 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/IPeopleApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IPeopleApiClient.cs @@ -1,6 +1,11 @@ -namespace Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Integration.Profile; +using Fusion.Integration.Profile.ApiClient; + +namespace Fusion.Resources.Functions.Common.ApiClients; public interface IPeopleApiClient { Task GetPersonFullDepartmentAsync(Guid? personAzureUniqueId); + + Task> ResolvePersonsAsync(IEnumerable personAzureUniqueIds, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/IResourcesApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/IResourcesApiClient.cs index 7241235f4..110d7cb06 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/IResourcesApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/IResourcesApiClient.cs @@ -17,6 +17,8 @@ public interface IResourcesApiClient Task> GetLeaveForPersonnel(string personId); Task> GetDelegatedResponsibleForDepartment(string departmentIdentifier); + Task> GetActiveRequestsForProjectAsync(Guid projectId, CancellationToken cancellationToken = default); + #region Models public class ResourceAllocationRequest diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs index e04ffab99..e2c5f7373 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs @@ -29,6 +29,16 @@ public Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummary /// public Task PutProjectAsync(ApiProject project, CancellationToken cancellationToken = default); + + /// + /// When putting a weekly task owner report, it will replace the existing report for the given period based on the project id. + /// If the report does not exist, it will be created. Duration should be from Monday to Monday. + /// + /// The key is the combination of the project id, period start and period end. + /// + /// + /// + public Task PutWeeklyTaskOwnerReportAsync(Guid projectId, ApiWeeklyTaskOwnerReport report, CancellationToken cancellationToken = default); } #region Models @@ -49,7 +59,6 @@ public ApiResourceOwnerDepartment() public Guid[] ResourceOwnersAzureUniqueId { get; init; } = null!; public Guid[] DelegateResourceOwnersAzureUniqueId { get; init; } = null!; - } public record ApiCollection(ICollection Items); @@ -103,4 +112,43 @@ public class ApiProject public Guid[] AssignedAdminsAzureUniqueId { get; set; } = []; } +public class ApiWeeklyTaskOwnerReport +{ + public required Guid Id { get; set; } + public required Guid ProjectId { get; set; } + public required DateTime PeriodStart { get; set; } + public required DateTime PeriodEnd { get; set; } + + public required int ActionsAwaitingTaskOwnerAction { get; set; } + public required ApiAdminAccessExpiring[] AdminAccessExpiringInLessThanThreeMonths { get; set; } + public required ApiPositionAllocationEnding[] PositionAllocationsEndingInNextThreeMonths { get; set; } + public required ApiTBNPositionStartingSoon[] TBNPositionsStartingInLessThanThreeMonths { get; set; } +} + +public class ApiAdminAccessExpiring +{ + public required Guid AzureUniqueId { get; set; } + public required string FullName { get; set; } + public required DateTime Expires { get; set; } +} + +public class ApiPositionAllocationEnding +{ + public required string PositionExternalId { get; set; } + + public required string PositionName { get; set; } + + public required string PositionNameDetailed { get; set; } + + public required DateTime PositionAppliesTo { get; set; } +} + +public class ApiTBNPositionStartingSoon +{ + public required string PositionExternalId { get; set; } + public required string PositionName { get; set; } + public required string PositionNameDetailed { get; set; } + public required DateTime PositionAppliesFrom { get; set; } +} + #endregion \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/OrgApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/OrgApiClient.cs index cacf141d7..8139f93cb 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/OrgApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/OrgApiClient.cs @@ -28,4 +28,10 @@ public Task> GetProjectsAsync(ODataQuery? query = null, Cance var url = ODataQuery.ApplyQueryString("/projects", query); return orgClient.GetAsJsonAsync>(url, cancellationToken: cancellationToken); } + + public Task> GetProjectPositions(string projectId, CancellationToken cancellationToken = default) + { + var url = $"/projects/{projectId}/positions"; + return orgClient.GetAsJsonAsync>(url, cancellationToken); + } } \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/PeopleApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/PeopleApiClient.cs index 223dbb570..524f99ba5 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/PeopleApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/PeopleApiClient.cs @@ -1,4 +1,6 @@ -using Fusion.Resources.Functions.Common.Integration.Http; +using Fusion.Integration.Profile; +using Fusion.Integration.Profile.ApiClient; +using Fusion.Resources.Functions.Common.Integration.Http; namespace Fusion.Resources.Functions.Common.ApiClients; @@ -19,4 +21,15 @@ public async Task GetPersonFullDepartmentAsync(Guid? personAzureUniqueId return data.FullDepartment; } + + public async Task> ResolvePersonsAsync(IEnumerable personAzureUniqueIds, CancellationToken cancellationToken = default) + { + var resp = await peopleClient + .PostAsJsonAsync>($"/persons/ensure?api-version=3.0", new + { + personIdentifiers = personAzureUniqueIds.Select(p => p.ToString()) + }, cancellationToken); + + return resp; + } } \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ResourcesApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ResourcesApiClient.cs index 74199bd50..7105a59d0 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/ResourcesApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ResourcesApiClient.cs @@ -43,7 +43,7 @@ public async Task> GetAllRequestsForDepar return response.Value.ToList(); } - catch(Exception ex) + catch (Exception ex) { log.LogError($"Error getting requests for department '{departmentIdentifier}'", ex); @@ -57,7 +57,7 @@ public async Task> GetAllPersonnelForDepart try { var response = await resourcesClient.GetAsJsonAsync>( - $"departments/{departmentIdentifier}/resources/personnel?api-version=2.0&$includeCurrentAllocations=true"); + $"departments/{departmentIdentifier}/resources/personnel?api-version=2.0&$includeCurrentAllocations=true"); return response.Value.ToList(); } @@ -97,20 +97,28 @@ public async Task ReassignRequestAsync(ResourceAllocationRequest item, str } public async Task> GetDelegatedResponsibleForDepartment(string departmentIdentifier) - { + { var response = await resourcesClient.GetAsJsonAsync>($"departments/{departmentIdentifier}/delegated-resource-owners"); return response; } + public async Task> GetActiveRequestsForProjectAsync(Guid projectId, CancellationToken cancellationToken = default) + { + var response = await resourcesClient + .GetAsJsonAsync>($"projects/{projectId}/resources/requests?$Filter=state neq 'completed'&$top={int.MaxValue}", cancellationToken: cancellationToken); + + return response.Value; + } + internal class InternalCollection { - public InternalCollection(IEnumerable items) + public InternalCollection(ICollection items) { Value = items; } - public IEnumerable Value { get; set; } + public ICollection Value { get; set; } } } } \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs index e5391da21..15ac0d1d1 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs @@ -104,6 +104,15 @@ public async Task> GetProjectsAsync(CancellationToken ca jsonSerializerOptions, cancellationToken: cancellationToken) ?? []; } + public async Task PutWeeklyTaskOwnerReportAsync(Guid projectId, ApiWeeklyTaskOwnerReport report, CancellationToken cancellationToken = default) + { + using var body = new JsonContent(JsonSerializer.Serialize(report, jsonSerializerOptions)); + + using var response = await summaryClient.PutAsync($"projects/{projectId}/task-owners-summary-reports/weekly", body, cancellationToken); + + await ThrowIfUnsuccessfulAsync(response); + } + private async Task ThrowIfUnsuccessfulAsync(HttpResponseMessage response) => await response.ThrowIfUnsuccessfulAsync((responseBody) => new SummaryApiError(response, responseBody)); } diff --git a/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj b/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj index a4d6cd5d0..e92bc89ac 100644 --- a/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj +++ b/src/Fusion.Resources.Functions.Common/Fusion.Resources.Functions.Common.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientExtensions.cs b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientExtensions.cs index 26e561a79..336a8e227 100644 --- a/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientExtensions.cs +++ b/src/Fusion.Resources.Functions.Common/Integration/Http/HttpClientExtensions.cs @@ -1,4 +1,5 @@ -using Fusion.Resources.Functions.Common.Integration.Errors; +using System.Text; +using Fusion.Resources.Functions.Common.Integration.Errors; using Newtonsoft.Json; namespace Fusion.Resources.Functions.Common.Integration.Http @@ -17,6 +18,22 @@ public static async Task GetAsJsonAsync(this HttpClient client, string url return deserialized; } + public static async Task PostAsJsonAsync(this HttpClient client, string url, object data, CancellationToken cancellationToken = default) + { + var json = JsonConvert.SerializeObject(data); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(url, content, cancellationToken); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new ApiError(response.RequestMessage!.RequestUri!.ToString(), response.StatusCode, body, "Response from API call indicates error"); + + TResponse deserialized = JsonConvert.DeserializeObject(body); + return deserialized; + } + public static async Task> OptionsAsync(this HttpClient client, string url, CancellationToken cancellationToken = default) { var message = new HttpRequestMessage(HttpMethod.Options, url); @@ -26,6 +43,5 @@ public static async Task> OptionsAsync(this HttpClient clien return allowHeaders; } - } } \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Controllers/ResourceOwnerReportsController.cs b/src/Fusion.Summary.Api/Controllers/ResourceOwnerReportsController.cs index 94e9dfd82..699437205 100644 --- a/src/Fusion.Summary.Api/Controllers/ResourceOwnerReportsController.cs +++ b/src/Fusion.Summary.Api/Controllers/ResourceOwnerReportsController.cs @@ -21,7 +21,7 @@ public class ResourceOwnerReportsController : BaseController { [HttpGet("resource-owners-summary-reports/{sapDepartmentId}/weekly")] [MapToApiVersion("1.0")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ODataFilter(nameof(ApiWeeklySummaryReport.Period))] diff --git a/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportWorker.cs b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportWorker.cs new file mode 100644 index 000000000..9186367ca --- /dev/null +++ b/src/Fusion.Summary.Functions/Functions/TaskOwnerReports/WeeklyTaskOwnerReportWorker.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Resources.Functions.Common.Extensions; +using Fusion.Summary.Functions.Models; +using Fusion.Summary.Functions.ReportCreator; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Extensions.Logging; +using PersonIdentifier = Fusion.Integration.Profile.PersonIdentifier; + +namespace Fusion.Summary.Functions.Functions.TaskOwnerReports; + +public class WeeklyTaskOwnerReportWorker +{ + private readonly ISummaryApiClient summaryApiClient; + private readonly IResourcesApiClient resourcesApiClient; + private readonly IPeopleApiClient peopleApiClient; + private readonly IOrgClient orgApiClient; + private readonly ILogger logger; + + public WeeklyTaskOwnerReportWorker(ISummaryApiClient summaryApiClient, ILogger logger, IResourcesApiClient resourcesApiClient, IPeopleApiClient peopleApiClient, IOrgClient orgApiClient) + { + this.summaryApiClient = summaryApiClient; + this.logger = logger; + this.resourcesApiClient = resourcesApiClient; + this.peopleApiClient = peopleApiClient; + this.orgApiClient = orgApiClient; + } + + private const string FunctionName = "weekly-task-owner-report-worker"; + + [FunctionName(FunctionName)] + public async Task RunAsync( + [ServiceBusTrigger("%project_summary_weekly_queue%", Connection = "AzureWebJobsServiceBus")] + ServiceBusReceivedMessage message, ServiceBusMessageActions messageReceiver, CancellationToken cancellationToken) + { + var dto = await JsonSerializer.DeserializeAsync(message.Body.ToStream(), cancellationToken: cancellationToken); + + logger.LogInformation("{FunctionName} started with message: {MessageBody}", FunctionName, dto.ToJson()); + try + { + await CreateAndStoreReportAsync(dto, cancellationToken); + await messageReceiver.CompleteMessageAsync(message, cancellationToken); + logger.LogInformation($"{FunctionName} completed successfully"); + } + catch (Exception e) // Dead letter message + { + logger.LogError(e, $"{FunctionName} completed with error"); + throw; + } + } + + private async Task CreateAndStoreReportAsync(WeeklyTaskOwnerReportMessage message, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow.Date; + WeeklyTaskOwnerReportDataCreator.NowDate = now; + // Exclude Products + var allProjectPositions = (await orgApiClient.GetProjectPositions(message.OrgProjectExternalId.ToString(), cancellationToken)) + .Where(p => p.BasePosition.ProjectType != "Product").ToArray(); + var activeRequestsForProject = await resourcesApiClient.GetActiveRequestsForProjectAsync(message.OrgProjectExternalId, cancellationToken); + var admins = await ResolveAdminsAsync(message, cancellationToken); + + var expiringAdmins = WeeklyTaskOwnerReportDataCreator.GetExpiringAdmins(admins); + var actionsAwaitingTaskOwner = WeeklyTaskOwnerReportDataCreator.GetActionsAwaitingTaskOwnerAsync(activeRequestsForProject); + var expiringPositionAllocations = WeeklyTaskOwnerReportDataCreator.GetPositionAllocationsEndingNextThreeMonths(allProjectPositions); + var tbnPositions = WeeklyTaskOwnerReportDataCreator.GetTBNPositionsStartingWithinThreeMonths(allProjectPositions, activeRequestsForProject); + + var lastMonday = now.GetPreviousWeeksMondayDate(); + var report = new ApiWeeklyTaskOwnerReport() + { + Id = Guid.Empty, + PeriodStart = lastMonday, + PeriodEnd = lastMonday.AddDays(7), + ProjectId = message.ProjectId, + ActionsAwaitingTaskOwnerAction = actionsAwaitingTaskOwner, + AdminAccessExpiringInLessThanThreeMonths = expiringAdmins.Select(ea => new ApiAdminAccessExpiring() + { + AzureUniqueId = ea.AzureUniqueId, + FullName = ea.FullName, + Expires = ea.ValidTo + }).ToArray(), + PositionAllocationsEndingInNextThreeMonths = expiringPositionAllocations.Select(ep => new ApiPositionAllocationEnding() + { + PositionName = ep.Position.BasePosition.Name ?? string.Empty, + PositionNameDetailed = ep.Position.Name, + PositionExternalId = ep.Position.ExternalId ?? string.Empty, + PositionAppliesTo = ep.ExpiresAt + }).ToArray(), + TBNPositionsStartingInLessThanThreeMonths = tbnPositions.Select(tp => new ApiTBNPositionStartingSoon() + { + PositionName = tp.Position.BasePosition.Name ?? string.Empty, + PositionNameDetailed = tp.Position.Name, + PositionExternalId = tp.Position.ExternalId ?? string.Empty, + PositionAppliesFrom = tp.StartsAt + }).ToArray() + }; + + + await summaryApiClient.PutWeeklyTaskOwnerReportAsync(message.ProjectId, report, cancellationToken); + } + + + private async Task> ResolveAdminsAsync(WeeklyTaskOwnerReportMessage message, CancellationToken cancellationToken) + { + if (message.ProjectAdmins.Length == 0) + return []; + + var personIdentifiers = message.ProjectAdmins.Select(pa => new PersonIdentifier(pa.AzureUniqueId, pa.Mail)); + + var resolvedAdmins = await peopleApiClient.ResolvePersonsAsync(personIdentifiers, cancellationToken); + + var admins = new List(); + + foreach (var resolvedPersonProfile in resolvedAdmins) + { + if (!resolvedPersonProfile.Success) + { + logger.LogWarning("Failed to resolve profile for {PersonIdentifier}", resolvedPersonProfile.Identifier); + continue; + } + + var profile = resolvedPersonProfile.Person!; + + if (profile.AzureUniqueId == null) + { + logger.LogError("Resolved profile for {PersonIdentifier} does not have AzureUniqueId", resolvedPersonProfile.Identifier); + continue; + } + + var projectAdmin = message.ProjectAdmins.First(pa => pa.AzureUniqueId == profile.AzureUniqueId || + pa.Mail != null && pa.Mail.Equals(profile.Mail, StringComparison.OrdinalIgnoreCase)); + + if (projectAdmin.ValidTo == null) + continue; + + admins.Add(new PersonAdmin(profile.AzureUniqueId.Value, profile.Name, projectAdmin.ValidTo.Value.DateTime)); + } + + + return admins; + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummarySender.cs b/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummarySender.cs index cbfd031ce..f0806383d 100644 --- a/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummarySender.cs +++ b/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummarySender.cs @@ -33,7 +33,7 @@ public WeeklyDepartmentSummarySender(ISummaryApiClient summaryApiClient, INotifi this.logger = logger; this.configuration = configuration; - _maxDegreeOfParallelism = int.TryParse(configuration["weekly-department-summary-sender-parallelism"], out var result) ? result : 2; + _maxDegreeOfParallelism = int.TryParse(configuration["weekly-department-summary-sender-parallelism"], out var result) ? result : 1; _departmentFilter = configuration["departmentFilter"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? ["PRD"]; // Need to explicitly add the configuration key to the app settings to disable sending of notifications diff --git a/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs b/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs new file mode 100644 index 000000000..056daad10 --- /dev/null +++ b/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Services.Org.ApiModels; + +namespace Fusion.Summary.Functions.ReportCreator; + +public abstract class WeeklyTaskOwnerReportDataCreator +{ + public static DateTime NowDate { get; set; } + + // Logic taken/inspired from the frontend + // https://github.com/equinor/fusion-resource-allocation-apps/blob/a9330b2aa8d104e51536692a72334252d5e474e1/apps/org-admin/src/pages/ProjectPage/components/ChartComponent/components/utils.ts#L28 + public static List GetTBNPositionsStartingWithinThreeMonths(IEnumerable allProjectPositions, + ICollection requests) + { + var nowDate = NowDate; + + var tbnPositions = new List(); + + foreach (var position in allProjectPositions) + { + if (IsSupportPosition(position)) + continue; + + var isPositionActive = position.Instances.Any(i => i.AppliesFrom.Date <= nowDate.Date && i.AppliesTo.Date >= nowDate.Date); + + if (isPositionActive) + continue; + + var futureInstances = position.Instances.Where(i => i.AppliesFrom.Date >= nowDate.Date).ToList(); + + if (futureInstances.Count == 0) + continue; + + var startingInstance = futureInstances.MinBy(i => i.AppliesFrom); // Get the instance starting soonest + + if (startingInstance is null) + continue; + + var instanceHasPersonalRequest = requests.Any(r => r.OrgPositionInstance?.Id == startingInstance.Id); + + if (instanceHasPersonalRequest) + continue; + + if (startingInstance.AppliesFrom.Date < nowDate.AddMonths(3).Date && startingInstance.AssignedPerson is null) + tbnPositions.Add(new TBNPosition(position, startingInstance.AppliesFrom)); + } + + return tbnPositions; + } + + // https://github.com/equinor/fusion-resource-allocation-apps/blob/0c8477f48021c594af20c0b1ba7b549b187e2e71/apps/org-admin/src/pages/ProjectPage/utils.ts#L86 + private static bool IsSupportPosition(ApiPositionV2 position) + { + var supportNames = new[] { "support", "advisor", "assistance" }; + return supportNames.Any(s => position.Name.Contains(s, StringComparison.OrdinalIgnoreCase)); + } + + public static List GetPositionAllocationsEndingNextThreeMonths(IEnumerable allProjectPositions) + { + /* + * Remember that it's the position*Allocation* that is expiring, not the position itself. + * So a position allocation can be considered expiring if: + * 1. The position is active and the next split within the next 3 months does not have a person assigned + * - We want to notify task owners that an upcoming split is missing an allocation and that they should assign someone + * + * 2. The last split is expiring within the next 3 months. + * There can be more splits after this but if they're not starting (appliesFrom) within the next 3 months (from NowDate), we consider the position expiring. + * Once the later split comes within the 3-month window, the position will fall under TBNPositionsStartingWithinThreeMonths + * - We want to notify task owners that the position is expiring + * + * Note: If there is a gap between two splits and the gap/time-period is fully within the 3-month window, + * then we DO NOT consider the position expiring. This is also an unusual case. + */ + + var nowDate = NowDate; + var expiringDate = nowDate.AddMonths(3); + + var expiringPositions = new List(); + + foreach (var position in allProjectPositions) + { + if (position.Instances.Count == 0) // No instances, skip + continue; + + var activeInstance = position.Instances.FirstOrDefault(i => i.AppliesFrom <= nowDate && i.AppliesTo >= nowDate); + + + // Find future instances with a start date within the 3-month window that may or may not end within the 3-month window + var futureInstances = position.Instances + .Where(i => i.AppliesFrom >= nowDate && i.AppliesFrom < expiringDate) + .OrderBy(i => i.AppliesFrom) + .ToList(); + + // Handle case where the position is not currently active + if (activeInstance is null) + { + if (futureInstances.Count == 0) + continue; // This is a past position + + var endingPositionAllocation = FindFirstTBNOrLastExpiringInstance(futureInstances); + + if (endingPositionAllocation is null) + // No TBN/expiring instances found within the 3-month window, could be an instance that starts within the 3-month window + // But ends after the 3-month window + continue; + + // If the first TBN/expiring instance found is not the last instance, then there are more instances after it + var isEndingInstanceLast = futureInstances.Last() == endingPositionAllocation; + + if (isEndingInstanceLast || endingPositionAllocation.AssignedPerson is null) + expiringPositions.Add(new ExpiringPosition(position, endingPositionAllocation.AppliesTo)); + + continue; + } + + + // Handle case where the position is currently active + + var isActiveInstanceExpiring = activeInstance.AppliesTo < expiringDate; + + if (isActiveInstanceExpiring && futureInstances.Count == 0) + { + expiringPositions.Add(new ExpiringPosition(position, activeInstance.AppliesTo)); + continue; + } + + if (isActiveInstanceExpiring) + { + var endingPositionAllocation = FindFirstTBNOrLastExpiringInstance(futureInstances); + + if (endingPositionAllocation is not null) + expiringPositions.Add(new ExpiringPosition(position, endingPositionAllocation.AppliesTo)); + } + + // The instance is active and not expiring, continue to next position + } + + + return expiringPositions; + } + + private static ApiPositionInstanceV2? FindFirstTBNOrLastExpiringInstance(IEnumerable futureOrderedInstances) + { + ApiPositionInstanceV2? lastExpiringInstance = null; + foreach (var instance in futureOrderedInstances) + { + if (instance.AssignedPerson is null) + return instance; // We found a TBN instance + + if (instance.AppliesTo < NowDate.AddMonths(3)) + lastExpiringInstance = instance; // We found an expiring instance + } + + return lastExpiringInstance; + } + + // https://github.com/equinor/fusion-resource-allocation-apps/blob/0c8477f48021c594af20c0b1ba7b549b187e2e71/apps/org-admin/src/pages/ProjectPage/utils.ts#L53 + public static int GetActionsAwaitingTaskOwnerAsync(IEnumerable requests) + { + return requests + .Where(r => r.State is not null && !r.State.Equals("Completed", StringComparison.OrdinalIgnoreCase)) + .Count(r => (r.HasProposedPerson && !r.State!.Equals("Created", StringComparison.OrdinalIgnoreCase) && !r.IsDraft) || r.Type == "ResourceOwnerChange"); + } + + public static List GetExpiringAdmins(IEnumerable activeAdmins) + { + var now = NowDate; + + return activeAdmins.Where(a => a.ValidTo <= now.AddMonths(3)).ToList(); + } +} + +public record PersonAdmin(Guid AzureUniqueId, string FullName, DateTime ValidTo); + +public record ExpiringPosition(ApiPositionV2 Position, DateTime ExpiresAt); + +public record TBNPosition(ApiPositionV2 Position, DateTime StartsAt); \ No newline at end of file diff --git a/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs b/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs new file mode 100644 index 000000000..e5d3571b4 --- /dev/null +++ b/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs @@ -0,0 +1,373 @@ +using System.Runtime.CompilerServices; +using FluentAssertions; +using Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Services.Org.ApiModels; +using Fusion.Summary.Functions.ReportCreator; + +namespace Fusion.Summary.Functions.Tests.Notifications; + +public class WeeklyTaskOwnerReportDataCreatorTests +{ + private readonly DateTime now; + + public WeeklyTaskOwnerReportDataCreatorTests() + { + now = DateTime.UtcNow.Date; + WeeklyTaskOwnerReportDataCreator.NowDate = now; + AssertionOptions.FormattingOptions.MaxDepth = 10; + AssertionOptions.FormattingOptions.MaxLines = 500; + } + + private DateTime Past => now.Subtract(TimeSpan.FromDays(1)); + + [Fact] + public void ActiveAdmins_AreConsideredExpiring_IfValidToIsLessThanThreeMonths() + { + var admins = new List() + { + new PersonAdmin(Guid.NewGuid(), "", now.Add(TimeSpan.FromDays(1))), // Is expiring + new PersonAdmin(Guid.NewGuid(), "", now.Add(TimeSpan.FromDays(50))), // Is expiring + new PersonAdmin(Guid.NewGuid(), "", now.Add(TimeSpan.FromDays(90))), // Is expiring + new PersonAdmin(Guid.NewGuid(), "", now.Add(TimeSpan.FromDays(120))), // Is not expiring + new PersonAdmin(Guid.NewGuid(), "", now.Add(TimeSpan.FromDays(365))) // Is not expiring + }; + + var data = WeeklyTaskOwnerReportDataCreator.GetExpiringAdmins(admins); + + data.Should().HaveCount(3); + } + + + // Most of the test cases taken from the image in the User story's AC + // https://statoil-proview.visualstudio.com/Fusion%20Resource%20Allocation/_workitems/edit/43190 + [Fact] + public void GetPositionAllocationsEndingNextThreeMonthsTest() + { + #region Arrange + + var personA = new ApiPersonV2() + { + AzureUniqueId = Guid.NewGuid(), + Name = "Test NameA" + }; + var personB = new ApiPersonV2() + { + AzureUniqueId = Guid.NewGuid(), + Name = "Test NameB" + }; + + var shouldBeIncludedInReport = new List(); + var positionsToTest = new List(); + var instanceToBeIncluded = new Dictionary(); + + var activeWithFutureInstance = new PositionBuilder() + .WithInstance(Past, now.AddDays(30 * 1.5), person: personA) + .AddNextInstance(TimeSpan.FromDays(30 * 4), person: personA) + .Build(); + AddPosition(activeWithFutureInstance); + + var activeWithoutFutureInstance = new PositionBuilder() + .WithInstance(Past, now.AddDays(30), person: personA) + .AddNextInstance(TimeSpan.FromDays(30), person: personA, extId: "1") + .Build(); + AddPosition(activeWithoutFutureInstance, shouldBeIncludedInReportList: true, instanceSelector: i => i.ExternalId == "1"); + + + var activeWithFutureInstanceDifferentPerson = new PositionBuilder() + .WithInstance(Past, now.AddDays(30 * 1.5), person: personA) + .AddNextInstance(TimeSpan.FromDays(30 * 4), person: personB) + .Build(); + AddPosition(activeWithFutureInstanceDifferentPerson); + + + var singleActiveWithoutFutureInstance = new PositionBuilder() + .WithInstance(Past, now.Add(TimeSpan.FromDays(30 * 1.5)), person: personA) + .Build(); + AddPosition(singleActiveWithoutFutureInstance, shouldBeIncludedInReportList: true); + + + var activeWithFutureInstanceUnassignedPerson = new PositionBuilder() + .WithInstance(Past, now.AddDays(30 * 2), person: personA) + .AddNextInstance(TimeSpan.FromDays(30 * 2), person: null) + .Build(); + AddPosition(activeWithFutureInstanceUnassignedPerson, shouldBeIncludedInReportList: true, instanceSelector: i => i.AssignedPerson is null); + + + var futureInstanceThatIsAlsoExpiring = new PositionBuilder() + .WithInstance(now.AddDays(30), now.AddDays(30 * 2), person: personA) + .Build(); + AddPosition(futureInstanceThatIsAlsoExpiring, shouldBeIncludedInReportList: true); + + + var futureInstancesThatIsAlsoExpiring = new PositionBuilder() + .WithInstance(now.AddDays(30), now.AddDays(30 * 2), person: personA) + .AddNextInstance(TimeSpan.FromDays(1), person: personA, extId: "1") + .Build(); + AddPosition(futureInstancesThatIsAlsoExpiring, shouldBeIncludedInReportList: true, instanceSelector: i => i.ExternalId == "1"); + + + var futureInstanceThatIsMissingAllocation = new PositionBuilder() + .WithInstance(now.AddDays(30), now.AddDays(30 * 2), person: personA) + .AddNextInstance(TimeSpan.FromDays(1), person: null) + .Build(); + AddPosition(futureInstanceThatIsMissingAllocation, shouldBeIncludedInReportList: true, instanceSelector: i => i.AssignedPerson is null); + + var futureInstancesWhereOneIsTBN = new PositionBuilder() + .WithInstance(now.AddDays(10), now.AddDays(30), person: personA) + .AddNextInstance(TimeSpan.FromDays(2), person: null) + .AddNextInstance(TimeSpan.FromDays(6), person: personA) + .Build(); + AddPosition(futureInstancesWhereOneIsTBN, shouldBeIncludedInReportList: true, instanceSelector: i => i.AssignedPerson is null); + + + var futureInstanceThatIsNotExpiring = new PositionBuilder() + .WithInstance(now.AddDays(30), now.AddDays(60), person: personA) + .AddNextInstance(TimeSpan.FromDays(100), person: personA) + .Build(); + AddPosition(futureInstanceThatIsNotExpiring); + + + // Entire gap/time-period is within the 3-month window + var activePositionWithFutureInstanceWithSmallGap = new PositionBuilder() + .WithInstance(Past, now.AddDays(30), person: personA) + .AddNextInstance(now.AddDays(30 * 2), now.AddDays(30 * 4), person: personA) + .Build(); + + AddPosition(activePositionWithFutureInstanceWithSmallGap); + + + var activePositionWithFutureInstanceWithLargerGap = new PositionBuilder() + .WithInstance(Past, now.AddDays(30), person: personA, extId: "1") + .AddNextInstance(now.AddDays(30 * 5), now.AddDays(30 * 7), person: personA) + .AddNextInstance(TimeSpan.FromDays(10), person: personA) + .Build(); + AddPosition(activePositionWithFutureInstanceWithLargerGap, shouldBeIncludedInReportList: true, instanceSelector: i => i.ExternalId == "1"); + + + var manySmallInstancesWithoutFutureInstance = new PositionBuilder() + .WithInstance(Past, now.AddDays(10), person: personA) + .AddNextInstance(TimeSpan.FromDays(10), person: personA) + .AddNextInstance(TimeSpan.FromDays(10), person: personA) + .AddNextInstance(TimeSpan.FromDays(10), person: personB, extId: "1") + .Build(); + AddPosition(manySmallInstancesWithoutFutureInstance, shouldBeIncludedInReportList: true, instanceSelector: i => i.ExternalId == "1"); + + + var endingPosition = new PositionBuilder() + .WithInstance(Past, now.AddMonths(2)) + .Build(); + AddPosition(endingPosition, shouldBeIncludedInReportList: true); + + + var nonEndingPosition = new PositionBuilder() + .WithInstance(Past, now.AddMonths(2), person: personA) + .AddNextInstance(TimeSpan.FromDays(31), person: personB) + .Build(); + AddPosition(nonEndingPosition); + + if (shouldBeIncludedInReport.Distinct().Count() != shouldBeIncludedInReport.Count) + throw new InvalidOperationException($"Test setup error: Duplicate position names in {nameof(shouldBeIncludedInReport)}"); + + if (positionsToTest.Distinct().Count() != positionsToTest.Count) + throw new InvalidOperationException($"Test setup error: Duplicate positions in {nameof(positionsToTest)}"); + + #endregion + + var data = WeeklyTaskOwnerReportDataCreator.GetPositionAllocationsEndingNextThreeMonths(positionsToTest); + + data.Should().OnlyHaveUniqueItems(); + foreach (var positionName in shouldBeIncludedInReport) + { + data.Should().ContainSingle(p => p.Position.Name == positionName, $"Position {positionName} should be included in the report"); + } + + // Ensure that the right expiry date is set + foreach (var (position, apiPositionInstanceV2) in instanceToBeIncluded) + { + data.Should().ContainSingle(p => p.ExpiresAt == apiPositionInstanceV2.AppliesTo && p.Position.Id == position.Id, $"Position {position.Name} should have an instance that expires at {apiPositionInstanceV2.AppliesTo}"); + } + + // Check that there are no extra positions that should not be included + data.Should().HaveSameCount(shouldBeIncludedInReport, "All positions that should be included in the report should be included"); + return; + + // Helper method + void AddPosition(ApiPositionV2 position, bool shouldBeIncludedInReportList = false, Func? instanceSelector = null, [CallerArgumentExpression("position")] string positionName = null!) + { + ArgumentNullException.ThrowIfNull(position); + + if (shouldBeIncludedInReportList) + shouldBeIncludedInReport.Add(positionName); + + positionsToTest.Add(position); + position.Name = positionName; + + if (shouldBeIncludedInReportList && instanceSelector is not null) + { + var instances = position.Instances.Where(instanceSelector).ToArray(); + + if (instances.Length == 0) + throw new InvalidOperationException($"Test setup error: No instance found for position {positionName} that matches the selector"); + + if (instances.Length > 1) + throw new InvalidOperationException($"Test setup error: Multiple instances found for position {positionName} that matches the selector"); + + instanceToBeIncluded.Add(position, instances.First()); + } + } + } + + [Fact] + public void GetTBNPositionsStartingWithinThreeMonthsTests() + { + var person = new ApiPersonV2() + { + AzureUniqueId = Guid.NewGuid(), + Name = "Test Name" + }; + + var activePositions = + new PositionBuilder() + .WithInstance(now.Subtract(TimeSpan.FromDays(1)), now.AddMonths(2)) + .AddNextInstance(TimeSpan.FromDays(26)) + .Build(); + activePositions.Name = nameof(activePositions); + + + var nonActiveWithinThreeMonthsWithPerson = + new PositionBuilder() + .WithInstance(now.AddMonths(2), now.AddMonths(3), person) + .AddNextInstance(TimeSpan.FromDays(26)) + .Build(); + nonActiveWithinThreeMonthsWithPerson.Name = nameof(nonActiveWithinThreeMonthsWithPerson); + + + var nonActiveWithinThreeMonthsNoPersonButHasRequest = + new PositionBuilder() + .WithInstance(now.AddMonths(2), now.AddMonths(3)) + .Build(); + nonActiveWithinThreeMonthsNoPersonButHasRequest.Name = nameof(nonActiveWithinThreeMonthsNoPersonButHasRequest); + + + var request = new IResourcesApiClient.ResourceAllocationRequest() + { + Id = Guid.NewGuid(), + OrgPosition = new() + { + Id = nonActiveWithinThreeMonthsNoPersonButHasRequest.Id + } + }; + + var nonActiveOutsideThreeMonths = + new PositionBuilder() + .WithInstance(now.AddMonths(4), now.AddMonths(5)) + .Build(); + nonActiveOutsideThreeMonths.Name = nameof(nonActiveOutsideThreeMonths); + + + var nonActiveWithinThreeMonthsNoPerson = + new PositionBuilder() + .WithInstance(now.AddMonths(2), now.AddMonths(3)) + .Build(); + nonActiveWithinThreeMonthsNoPerson.Name = nameof(nonActiveWithinThreeMonthsNoPerson); + + + var data = WeeklyTaskOwnerReportDataCreator.GetTBNPositionsStartingWithinThreeMonths(new List + { + activePositions, + nonActiveWithinThreeMonthsWithPerson, + nonActiveWithinThreeMonthsNoPerson, + nonActiveOutsideThreeMonths + }, [request]); + + data.Should().ContainSingle(p => p.Position.Id == nonActiveWithinThreeMonthsNoPerson.Id); + } + + + private class PositionBuilder(DateTime nowParam = default) + { + private readonly DateTime now = nowParam == default ? DateTime.UtcNow : nowParam; + private readonly List instances = new(); + + public InstanceChainBuilder WithPastInstance(ApiPersonV2? person = null, string type = "Normal") + { + WithInstance(now.Subtract(TimeSpan.FromDays(30)), now.Subtract(TimeSpan.FromDays(1)), person, type); + return new InstanceChainBuilder(this); + } + + public InstanceChainBuilder WithFutureInstance(ApiPersonV2? person = null, string type = "Normal") + { + WithInstance(now.Add(TimeSpan.FromDays(1)), now.Add(TimeSpan.FromDays(30)), person, type); + return new InstanceChainBuilder(this); + } + + public InstanceChainBuilder WithInstance(DateTime appliesFrom, DateTime appliesTo, ApiPersonV2? person = null, string? extId = null, string type = "Normal") + { + instances.Add(new ApiPositionInstanceV2() + { + Id = Guid.NewGuid(), + ExternalId = extId, + AssignedPerson = person, + Type = type, + AppliesFrom = appliesFrom, + AppliesTo = appliesTo + }); + return new InstanceChainBuilder(this); + } + + public ApiPositionV2 Build() + { + var id = Guid.NewGuid(); + return new ApiPositionV2() + { + Id = Guid.NewGuid(), + Name = "TestName " + id, + ExternalId = "TestExternalId " + id, + BasePosition = new ApiPositionBasePositionV2() + { + Name = "TestBaseName " + id, + ProjectType = "PRD" + }, + Instances = instances + }; + } + + + public class InstanceChainBuilder(PositionBuilder builder) + { + private readonly PositionBuilder builder = builder; + + public InstanceChainBuilder AddNextInstance(TimeSpan duration, ApiPersonV2? person = null, string? extId = null, string type = "Normal") + { + builder.instances.Add(new ApiPositionInstanceV2() + { + Id = Guid.NewGuid(), + ExternalId = extId, + AssignedPerson = person, + Type = type, + AppliesFrom = builder.instances.Last().AppliesTo.AddDays(1), + AppliesTo = builder.instances.Last().AppliesTo.AddDays(1).Add(duration) + }); + return this; + } + + public InstanceChainBuilder AddNextInstance(DateTime appliesFrom, DateTime appliesTo, ApiPersonV2? person = null, string? extId = null, string type = "Normal") + { + builder.instances.Add(new ApiPositionInstanceV2() + { + Id = Guid.NewGuid(), + AssignedPerson = person, + Type = type, + AppliesFrom = appliesFrom, + AppliesTo = appliesTo + }); + return this; + } + + public ApiPositionV2 Build() + { + return builder.Build(); + } + } + } +} \ No newline at end of file