From aa4948e0a9b84a162c18acb3a9ce975d5d3c1137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 2 Sep 2026 17:25:15 +0200 Subject: [PATCH] feat(flex-chain): read path anchors on the FlexChainComputedThrough cursor Adds PlanRegistrationHelper.ResolveChainAnchor, expressing "seed from the latest row strictly before the window that the site's cursor vouches for" -- a row created ahead of time and never filled in (SumFlexEnd 0) can no longer be mistaken for the chain's true predecessor once its date passes. A null cursor (every tenant today, since the column is published but not yet backfilled) falls back to the exact prior behavior: latest row strictly before the window, seed from zero if none. Amends the six live preTimePlanning anchor queries (four in PlanRegistrationHelper, two in TimePlanningPlanningService's Index and IndexByCurrentUserName) with the same predicate so EF filters at the database, matching the in-memory helper. Closes out the flex-chain-continuity plan (task 7 of 7). --- .../PlanRegistrationHelperTests.cs | 42 +++++++++++++++++++ .../Helpers/PlanRegistrationHelper.cs | 24 +++++++++++ .../TimePlanningPlanningService.cs | 2 + 3 files changed, 68 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index bb52df4f..b2ef0a4a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -1703,4 +1703,46 @@ public void GetDeclaredPayCodes_EmptyRuleSet_ReturnsEmpty() { Assert.That(TimePlanningWorkingHoursService.GetDeclaredPayCodes(new PayRuleSet()), Is.Empty); } + + [Test] + public void Anchor_SkipsRowsBeyondTheCursor_AndSeedsFromTheLastComputedRow() + { + // A site whose chain is computed through 2026-06-10. The 06-11 row was + // created ahead of time and never filled in -- SumFlexEnd 0 -- so anchoring + // on it would discard the balance. The anchor must be the 06-10 row. + var site = new AssignedSite + { + SiteId = 1, + FlexChainComputedThrough = new DateTime(2026, 6, 10) + }; + var computed = new PlanRegistration + { + SdkSitId = 1, Date = new DateTime(2026, 6, 10), SumFlexEnd = 42.5 + }; + var uncomputed = new PlanRegistration + { + SdkSitId = 1, Date = new DateTime(2026, 6, 11), SumFlexEnd = 0 + }; + + var anchor = PlanRegistrationHelper.ResolveChainAnchor( + new[] { computed, uncomputed }, site, new DateTime(2026, 6, 12)); + + Assert.That(anchor, Is.SameAs(computed)); + } + + [Test] + public void Anchor_WithNullCursor_FallsBackToTheImmediatelyPrecedingRow() + { + var site = new AssignedSite { SiteId = 1, FlexChainComputedThrough = null }; + var previous = new PlanRegistration + { + SdkSitId = 1, Date = new DateTime(2026, 6, 11), SumFlexEnd = 7.0 + }; + + var anchor = PlanRegistrationHelper.ResolveChainAnchor( + new[] { previous }, site, new DateTime(2026, 6, 12)); + + Assert.That(anchor, Is.SameAs(previous), + "a null cursor means nothing is known -- behave exactly as before"); + } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs index 41c86a7c..23e90fe8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -364,6 +364,26 @@ public static void RecalculatePlanHoursFromShifts(PlanRegistration pr, bool useO RecalculatePlanHoursFromShifts(pr); } + /// + /// The row a chain recompute should seed from: the latest candidate strictly + /// before that the site's cursor vouches for. + /// A row after the cursor may be one created ahead of time and never filled + /// in, whose zero would silently discard the accumulated balance. + /// A null cursor means nothing is known, so behave exactly as before. + /// + public static PlanRegistration? ResolveChainAnchor( + IEnumerable candidates, AssignedSite site, DateTime windowStart) + { + var eligible = candidates.Where(x => x.Date < windowStart); + + if (site.FlexChainComputedThrough is { } cursor) + { + eligible = eligible.Where(x => x.Date <= cursor); + } + + return eligible.OrderByDescending(x => x.Date).FirstOrDefault(); + } + public static async Task UpdatePlanRegistrationsInPeriod( List planningsInPeriod, TimePlanningPlanningModel siteModel, @@ -495,6 +515,7 @@ await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < planRegistration.Date && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); @@ -787,6 +808,7 @@ await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < planRegistration.Date && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); @@ -1180,6 +1202,7 @@ await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < planRegistration.Date && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); @@ -1460,6 +1483,7 @@ await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < planRegistration.Date && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 95fbbfad..bd95b632 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -381,6 +381,7 @@ await innerDbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < missingDate && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); @@ -595,6 +596,7 @@ await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < missingDate && x.SdkSitId == dbAssignedSite.SiteId) + .Where(x => dbAssignedSite.FlexChainComputedThrough == null || x.Date <= dbAssignedSite.FlexChainComputedThrough) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync();