diff --git a/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs b/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs index c913614df..8d2b276cf 100644 --- a/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs +++ b/src/Fusion.Summary.Functions/ReportCreator/WeeklyTaskOwnerReportDataCreator.cs @@ -49,21 +49,6 @@ private static bool IsSupportPosition(ApiPositionV2 position) 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 with an assigned person, 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); @@ -71,79 +56,112 @@ public static List GetPositionAllocationsEndingNextThreeMonths foreach (var position in allProjectPositions) { + position.Instances = position.Instances.OrderBy(i => i.AppliesFrom).ToList(); if (position.Instances.Count == 0) // No instances, skip continue; - var activeInstance = position.Instances.FirstOrDefault(i => i.AppliesFrom <= nowDate && i.AppliesTo >= nowDate && i.AssignedPerson is not null); + var instancesWithinPeriod = FindInstancesWithinPeriod(position, new DateTimeRange() { Start = nowDate, Stop = expiringDate }); - // 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(); + if (instancesWithinPeriod.EndingWithinPeriod.Count == 0 + && instancesWithinPeriod.ContainedWithinPeriod.Count == 0 + && instancesWithinPeriod.StartingWithinPeriod.Any()) + { + continue; + } - // Handle case where the position is not currently active - if (activeInstance is null) + foreach (var instance in instancesWithinPeriod.Instances) { - if (futureInstances.Count == 0) - continue; // This is a past position + if ((instancesWithinPeriod.ContainedWithinPeriod.Contains(instance) || instancesWithinPeriod.StartingWithinPeriod.Contains(instance)) + && instance.AssignedPerson is null) + { + expiringPositions.Add(new ExpiringPosition(position, instance.AppliesTo)); + break; + } + + if (instancesWithinPeriod.AnyInstancesAfter(instance) == false) + { + expiringPositions.Add(new ExpiringPosition(position, instance.AppliesTo)); + break; + } + } + } - 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; + return expiringPositions; + } - // If the first TBN/expiring instance found is not the last instance, then there are more instances after it - var isEndingInstanceLast = futureInstances.Last() == endingPositionAllocation; + private static InstancesWithinPeriod FindInstancesWithinPeriod(ApiPositionV2 position, DateTimeRange period) + { + var instancesEndingWithinPeriod = position.Instances + .Where(i => period.Contains(i.AppliesTo) && !period.Contains(i.AppliesFrom)) + .ToList(); - if ((isEndingInstanceLast || endingPositionAllocation.AssignedPerson is null) && endingPositionAllocation.AppliesTo < expiringDate) - expiringPositions.Add(new ExpiringPosition(position, endingPositionAllocation.AppliesTo)); + var instancesContainingPeriod = position.Instances + .Where(i => period.Contains(i.AppliesFrom) && period.Contains(i.AppliesTo)) + .ToList(); - continue; - } + var instancesStartingWithinPeriod = position.Instances + .Except(instancesContainingPeriod) + .Where(i => period.Contains(i.AppliesFrom) && !period.Contains(i.AppliesTo)) + .ToList(); + var instancesStartingAfterPeriod = position.Instances + .Where(i => i.AppliesFrom > period.Stop) + .ToList(); - // Handle case where the position is currently active + var lastInstance = instancesStartingAfterPeriod.LastOrDefault(); - var isActiveInstanceExpiring = activeInstance.AppliesTo < expiringDate; - if (isActiveInstanceExpiring && futureInstances.Count == 0) - { - expiringPositions.Add(new ExpiringPosition(position, activeInstance.AppliesTo)); - continue; - } + return new InstancesWithinPeriod() + { + EndingWithinPeriod = instancesEndingWithinPeriod, + ContainedWithinPeriod = instancesContainingPeriod, + StartingWithinPeriod = instancesStartingWithinPeriod, + LastInstance = lastInstance + }; + } - if (isActiveInstanceExpiring) - { - var endingPositionAllocation = FindFirstTBNOrLastExpiringInstance(futureInstances); - if (endingPositionAllocation is not null) - expiringPositions.Add(new ExpiringPosition(position, endingPositionAllocation.AppliesTo)); - } + private class InstancesWithinPeriod + { + public ApiPositionInstanceV2? LastInstance { get; set; } + public required List EndingWithinPeriod { get; set; } + public required List ContainedWithinPeriod { get; set; } + public required List StartingWithinPeriod { get; set; } // Starting within period and ending after period - // The instance is active and not expiring, continue to next position - } + public List Instances => EndingWithinPeriod.Concat(ContainedWithinPeriod).Concat(StartingWithinPeriod).Distinct().ToList(); + public bool AnyInstancesAfter(ApiPositionInstanceV2 instance) + { + // In this case the instance outlasts the period and is outside of scope + if (StartingWithinPeriod.Contains(instance)) + return true; - return expiringPositions; + return Instances.Except([instance]).Any(i => i.AppliesFrom > instance.AppliesTo); + } } - private static ApiPositionInstanceV2? FindFirstTBNOrLastExpiringInstance(IEnumerable futureOrderedInstances) + + private class DateTimeRange { - ApiPositionInstanceV2? lastExpiringInstance = null; - foreach (var instance in futureOrderedInstances) + public DateTime Start { get; set; } + public DateTime Stop { get; set; } + + public bool Overlaps(DateTimeRange other) { - if (instance.AssignedPerson is null) - return instance; // We found a TBN instance + return Start < other.Stop && Stop > other.Start; + } - if (instance.AppliesTo < NowDate.AddMonths(3)) - lastExpiringInstance = instance; // We found an expiring instance + public bool Contains(DateTime dateTime) + { + return Start <= dateTime && dateTime <= Stop; } - return lastExpiringInstance; + public TimeSpan Duration() + { + return Stop - Start; + } } // https://github.com/equinor/fusion-resource-allocation-apps/blob/0c8477f48021c594af20c0b1ba7b549b187e2e71/apps/org-admin/src/pages/ProjectPage/utils.ts#L53 diff --git a/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs b/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs index f1574a6a7..49e4e862e 100644 --- a/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs +++ b/src/tests/Fusion.Summary.Functions.Tests/Notifications/WeeklyTaskOwnerReportDataCreator.cs @@ -93,7 +93,7 @@ public void GetPositionAllocationsEndingNextThreeMonthsTest() var futureInstanceThatIsAlsoExpiring = new PositionBuilder() - .WithInstance(now.AddDays(30), now.AddDays(30 * 2), person: personA) + .WithInstance(now.AddDays(30), now.AddDays(30 * 1.5), person: personA) .Build(); testData.AddPosition(futureInstanceThatIsAlsoExpiring, shouldBeIncludedInReportList: true); @@ -165,6 +165,16 @@ public void GetPositionAllocationsEndingNextThreeMonthsTest() testData.AddPosition(nonActivePositionWithPastAllocationAndFutureTBN); + + var testPos = new PositionBuilder() + .WithInstance(now.AddMonths(-6), now.AddMonths(1), person: personA) + .AddNextInstance(TimeSpan.FromDays(48), personA) + .AddNextInstance(TimeSpan.FromDays(90), personA) + .Build(); + + testData.AddPosition(testPos); + + var shouldBeIncludedInReport = testData.ShouldBeIncludedInReport; var positionsToTest = testData.PositionsToTest; var instanceToBeIncluded = testData.InstanceToBeIncluded; @@ -192,7 +202,7 @@ public void GetPositionAllocationsEndingNextThreeMonthsTest() } // Check that there are no extra positions that should not be included - data.Should().HaveSameCount(shouldBeIncludedInReport, $"Exactly these positions should be included in the report, {string.Join(", ", shouldBeIncludedInReport)}, these should not be included {string.Join(", ", positionsToTest.Select(p => p.Name).Except(shouldBeIncludedInReport))}"); + data.Should().HaveSameCount(shouldBeIncludedInReport, $"Exactly these positions should be included in the report: {string.Join(", ", shouldBeIncludedInReport)}. These should not be included: {string.Join(", ", data.Select(p => p.Position.Name).Where(p => !shouldBeIncludedInReport.Contains(p)))}"); } [Fact] @@ -278,7 +288,7 @@ public void GetTBNPositionsStartingWithinThreeMonthsTests() // Check that there are no extra positions that should not be included data.Should().HaveSameCount(testData.ShouldBeIncludedInReport, $"Exactly these positions should be included in the report, {string.Join(", ", testData.ShouldBeIncludedInReport)}," + - $" these should not be included {string.Join(", ", testData.PositionsToTest.Select(p => p.Name).Except(testData.ShouldBeIncludedInReport))}"); + $" these should not be included {string.Join(", ", data.Select(p => p.Position.Name).Where(p => !testData.ShouldBeIncludedInReport.Contains(p)))}"); }