From c57188e3fdefeeaf9490de20838e3bfd655d3c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 2 Sep 2026 15:01:39 +0200 Subject: [PATCH 1/3] refactor(flex-chain): delegate to Microting.TimePlanningBase FlexChain Bumps Microting.TimePlanningBase to 10.0.62 and deletes the plugin's own copies of the running flex-chain arithmetic and OneMinuteModeTimeline now that both live in the base package as FlexChain and Microting.TimePlanningBase.Infrastructure.Helpers.OneMinuteModeTimeline. PlanRegistrationHelper keeps its orchestration (UpdatePlanRegistrationsInPeriod, UpdatePlanRegistration, ReadBySiteAndDate, ...) and delegates arithmetic to FlexChain. Call sites across the plugin (services, GoogleSheetHelper) and the test project are repointed from PlanRegistrationHelper.X(...) to FlexChain.X(...) directly rather than left as forwarders, so there is a single source of truth for the chain. EnumerateShiftPauseStampPairs was kept in PlanRegistrationHelper (unlike its sibling private helpers) because it is still used by the unrelated EnumeratePauseStampPairs -> GetPauseIntervals -> ComputeTimeTrackingFields path, which was out of scope for this move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .../ComputeShiftPauseSecondsTests.cs | 33 +- .../CorruptedPauseIdRepairTests.cs | 3 +- .../OneMinuteIntervalsEffectiveDateTests.cs | 25 +- .../OneMinuteModeTimelineTests.cs | 2 +- .../PlanRegistrationHelperTests.cs | 63 +-- .../PlanningServiceMultiShiftTests.cs | 9 +- .../RunningFlexChainModeBoundaryTests.cs | 1 + .../Helpers/GoogleSheetHelper.cs | 3 +- .../Helpers/OneMinuteModeTimeline.cs | 316 ------------- .../Helpers/PlanRegistrationHelper.cs | 438 +----------------- .../TimePlanningFlexService.cs | 7 +- .../TimePlanningPlanningService.cs | 25 +- .../TimeSettingService.cs | 1 + .../TimePlanningWorkingHoursService.cs | 38 +- .../TimePlanning.Pn/TimePlanning.Pn.csproj | 2 +- 15 files changed, 124 insertions(+), 842 deletions(-) delete mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs index 86aa0060..f5e71fea 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Planning; @@ -70,7 +71,7 @@ public void OffMode_Shift1_SumsAllSlots_FloorTo5Min_Returns30Min() { var reg = BuildRow16288Shape(); - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); Assert.That(result, Is.EqualTo(30 * 60), "Shift 1 OFF: 0 + 5 + 20 + 5 = 30 min across Pause1/Pause10/Pause11/Pause12"); @@ -81,7 +82,7 @@ public void OffMode_Shift2_SinglePrimarySlot_Returns5Min() { var reg = BuildRow16288Shape(); - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); Assert.That(result, Is.EqualTo(5 * 60), "Shift 2 OFF: Pause2 19:13:26 -> 19:16:52 crosses 19:15 = 5 min"); @@ -92,7 +93,7 @@ public void OnMode_Shift1_SumsExactSecondDeltasAcrossAllSlots_Returns1985s() { var reg = BuildRow16288Shape(); - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: true); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: true); // 162 (2m42s) + 523 (8m43s) + 1252 (20m52s) + 48 (0m48s) = 1985 s. Assert.That(result, Is.EqualTo(1985), @@ -104,7 +105,7 @@ public void OnMode_Shift2_ExactSecondDelta_Returns206s() { var reg = BuildRow16288Shape(); - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: true); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: true); // 19:16:52 - 19:13:26 = 3m26s = 206 s. Assert.That(result, Is.EqualTo(206), @@ -122,8 +123,8 @@ public void OffMode_DayTotalAcrossShifts_Returns35Min() { var reg = BuildRow16288Shape(); - var shift1 = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); - var shift2 = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); + var shift1 = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); + var shift2 = FlexChain.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); Assert.That(shift1 + shift2, Is.EqualTo(35 * 60), "Day total OFF = 30 + 5 = 35 min"); @@ -144,7 +145,7 @@ public void NoTimestampedSlot_FallsBackToLegacyPrimaryPauseId(bool useOneMinuteI Pause3Id = 4, // legacy 15-min pause: (4 - 1) * 5 = 15 min }; - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 3, useOneMinuteIntervals); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 3, useOneMinuteIntervals); Assert.That(result, Is.EqualTo(15 * 60), "No timestamped slot => legacy primary Pause3Id fallback = 15 min"); @@ -170,7 +171,7 @@ public void OrphanedStartOnlySlot_FallsBackToLegacyPrimaryPauseId(bool useOneMin Pause1Id = 4, // legacy 15-min pause: (4 - 1) * 5 = 15 min }; - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); Assert.That(result, Is.EqualTo(15 * 60), "Orphaned start-only slot is not a complete slot => legacy Pause1Id fallback = 15 min"); @@ -190,7 +191,7 @@ public void PartialTimestamp_StartedAtOnly_FallsBackToLegacyPauseId() Pause1StartedAt = new DateTime(2026, 6, 19, 10, 0, 0, DateTimeKind.Utc), Pause1StoppedAt = null, }; - Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false), Is.EqualTo(900), + Assert.That(FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false), Is.EqualTo(900), "Half-record (StartedAt only) must not suppress the legacy PauseId fallback"); } @@ -210,7 +211,7 @@ public void Override_WinsOverSlotSum(bool useOneMinuteIntervals) var reg = BuildRow16288Shape(); reg.Pause1OverrideMinutes = 12; - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); Assert.That(result, Is.EqualTo(12 * 60), "Override must replace the slot sum entirely."); @@ -227,7 +228,7 @@ public void Override_Zero_MeansZeroPause(bool useOneMinuteIntervals) var reg = BuildRow16288Shape(); reg.Pause1OverrideMinutes = 0; - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); Assert.That(result, Is.EqualTo(0), "Override = 0 means zero pause, not the slot sum."); @@ -243,7 +244,7 @@ public void Override_Null_FallsBackToSlotSum() var reg = BuildRow16288Shape(); reg.Pause1OverrideMinutes = null; - var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); + var result = FlexChain.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); Assert.That(result, Is.EqualTo(30 * 60), "Null override must fall back to the all-slots sum (30 min)."); @@ -259,7 +260,7 @@ public void Override_IsPerShift_DoesNotLeakAcrossShifts() var reg = BuildRow16288Shape(); reg.Pause1OverrideMinutes = 99; - var shift2 = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); + var shift2 = FlexChain.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); Assert.That(shift2, Is.EqualTo(5 * 60), "Shift 2 has no override and must still sum its own slot (5 min)."); @@ -306,7 +307,7 @@ public void Inference_EditOnlyStartStop_NonFiveMinSlotSum_LeavesOverrideNull() Pause10StoppedAt = Date.AddHours(11).AddMinutes(20), }; // Sanity: the slot sum is 33 min, not a 5-min multiple. - Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, true), + Assert.That(FlexChain.ComputeShiftPauseSeconds(reg, 1, true), Is.EqualTo(33 * 60), "Pre-condition: slot sum is 33 min."); var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); @@ -316,7 +317,7 @@ public void Inference_EditOnlyStartStop_NonFiveMinSlotSum_LeavesOverrideNull() Assert.That(reg.Pause1OverrideMinutes, Is.Null, "Unchanged pause (Break == pre-edit Pause1Id) must NOT lock an override."); - Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, true), + Assert.That(FlexChain.ComputeShiftPauseSeconds(reg, 1, true), Is.EqualTo(33 * 60), "Netto pause still reflects the recorded slot sum."); } @@ -419,7 +420,7 @@ public void ExplicitClear_RevertsOverrideToNull_FallsBackToSlotSum() Assert.That(reg.Pause1OverrideMinutes, Is.Null, "Explicit clear must revert the override to null."); - Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, false), + Assert.That(FlexChain.ComputeShiftPauseSeconds(reg, 1, false), Is.EqualTo(5 * 60), "After clear, pause falls back to the recorded slot sum (5 min)."); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs index f8e0611a..c01a3983 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs @@ -5,6 +5,7 @@ using Microting.TimePlanningBase.Infrastructure.Data; using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; @@ -46,7 +47,7 @@ public void ComputeNetto_PrefersPauseTimestamps_OverCorruptPauseId() Pause1Id = 145, // CORRUPT absolute tick (12:00) }; - var netto = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); + var netto = FlexChain.ComputeNettoSecondsFromDateTimeShifts(pr); // 8h - 30m = 7h30m = 27000s, derived from timestamps not the id. Assert.That(netto, Is.EqualTo(27000)); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs index ae9b1d79..952e1b51 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; @@ -219,12 +220,12 @@ public void SeedFallback_NullPredecessor_IsZero() { Assert.Multiple(() => { - Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0)); + Assert.That(FlexChain.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0)); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null, preIsOneMinute: false), + FlexChain.SumFlexEndSecondsWithFallback(null, preIsOneMinute: false), Is.EqualTo(0), "…whatever the mode argument says."); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null, preIsOneMinute: true), + FlexChain.SumFlexEndSecondsWithFallback(null, preIsOneMinute: true), Is.EqualTo(0)); }); } @@ -233,7 +234,7 @@ public void SeedFallback_NullPredecessor_IsZero() public void SeedFallback_PopulatedSecondsWin() { var pre = new PlanRegistration { SumFlexEndInSeconds = 7261, SumFlexEnd = 99 }; - Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre), Is.EqualTo(7261), + Assert.That(FlexChain.SumFlexEndSecondsWithFallback(pre), Is.EqualTo(7261), "When the seconds column is populated it is the source of truth."); } @@ -248,15 +249,15 @@ public void SeedFallback_ZeroSeconds_FallsBackToTheDecimalBalance() Assert.Multiple(() => { Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + FlexChain.SumFlexEndSecondsWithFallback( new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 12.5 }), Is.EqualTo(45000)); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + FlexChain.SumFlexEndSecondsWithFallback( new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = -2.25 }), Is.EqualTo(-8100), "A negative carried balance survives the fallback."); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + FlexChain.SumFlexEndSecondsWithFallback( new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 0 }), Is.EqualTo(0), "A genuine zero and an unbackfilled zero agree."); }); @@ -297,16 +298,16 @@ public void SeedFallback_FiveMinutePredecessor_IgnoresStaleSecondsColumn() Assert.Multiple(() => { Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: false), + FlexChain.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: false), Is.EqualTo(TrueDecimalSeconds), "A five-minute row carries its balance in the decimal ONLY; a " + "non-zero seconds column there is residue, never a balance."); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: true), + FlexChain.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: true), Is.EqualTo(StaleSeconds), "A one-minute predecessor keeps the seconds column as its truth."); Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre), + FlexChain.SumFlexEndSecondsWithFallback(pre), Is.EqualTo(StaleSeconds), "Unknown mode keeps the pre-existing behaviour."); }); @@ -319,7 +320,7 @@ public void SeedFallback_FiveMinutePredecessor_WithZeroSeconds_IsUnchanged() // rules agree and the decimal answers either way. var pre = new PlanRegistration { SumFlexEnd = 12.5, SumFlexEndInSeconds = 0 }; Assert.That( - PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: false), + FlexChain.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: false), Is.EqualTo(45000)); } @@ -365,7 +366,7 @@ public void MarkerFiveMinutePredecessorAfterTheEffectiveDate_SeedsFromTheDecimal PlanHoursInSeconds = 28800 }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( successor, pre, timeline.WasOneMinuteFor(pre)); Assert.Multiple(() => diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteModeTimelineTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteModeTimelineTests.cs index 76feede4..847b5d16 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteModeTimelineTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteModeTimelineTests.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using TimePlanning.Pn.Infrastructure.Helpers; +using Microting.TimePlanningBase.Infrastructure.Helpers; namespace TimePlanning.Pn.Test; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index 38ce7db9..bb52df4f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; @@ -489,7 +490,7 @@ public void NettoHours_FlagOn_DerivedFromDateTimeDeltasInSeconds() Pause1StoppedAt = null, }; - var nettoSeconds = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); + var nettoSeconds = FlexChain.ComputeNettoSecondsFromDateTimeShifts(pr); Assert.Multiple(() => { @@ -520,7 +521,7 @@ public void NettoHours_FlagOn_PauseDateTimeBeatsPauseId() Pause1StoppedAt = new DateTime(2026, 5, 15, 12, 0, 27), }; - var nettoSeconds = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); + var nettoSeconds = FlexChain.ComputeNettoSecondsFromDateTimeShifts(pr); // 8h work = 28800 s; pause from DateTime = 27 s; netto = 28773 s. Assert.That(nettoSeconds, Is.EqualTo(28773L), @@ -560,7 +561,7 @@ public void FlexAndSumFlex_FlagOn_DerivedFromInSecondsChain() // Pretend the previous day carried 30 min of positive flex forward. const int sumFlexStartInSeconds = 1800; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds, hasPreTimePlanning: true); Assert.Multiple(() => @@ -620,7 +621,7 @@ public void Flex_FlagOn_PlanHoursInSecondsZero_FallsBackToPlanHours() NettoHoursOverrideActive = false, }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds: 0, hasPreTimePlanning: false); Assert.Multiple(() => @@ -675,7 +676,7 @@ public void SumFlex_FlagOn_PaiedOutFlexInSecondsZero_FallsBackToPaiedOutFlex() NettoHoursOverrideActive = false, }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds: 0, hasPreTimePlanning: false); Assert.Multiple(() => @@ -716,7 +717,7 @@ public void SumFlex_FlagOn_NoPreceding_StartsAtZero() PaiedOutFlexInSeconds = 0, }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds: 0, hasPreTimePlanning: false); Assert.Multiple(() => @@ -755,7 +756,7 @@ public void Flex_FlagOn_OverrideActive_UsesOverrideForChain() NettoHoursOverrideActive = true, }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds: 0, hasPreTimePlanning: false); Assert.Multiple(() => @@ -794,7 +795,7 @@ public void PaiedOutFlex_FlagOn_DerivedFromInSeconds() PaiedOutFlexInSeconds = 1800, }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( pr, sumFlexStartInSeconds: 0, hasPreTimePlanning: false); Assert.Multiple(() => @@ -1053,7 +1054,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_RoundTrips3MinPause() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(3)); @@ -1078,7 +1079,7 @@ public void AggregatePauseMinutes_LegacyMode_3MinPauseReturns0_DocumentsLossy() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); // Assert Assert.That(result, Is.EqualTo(0)); @@ -1102,7 +1103,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_SumsAcrossMultipleSlots() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(10)); @@ -1122,7 +1123,7 @@ public void AggregatePauseMinutes_LegacyMode_15MinPauseReturns15() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); // Assert Assert.That(result, Is.EqualTo(15)); @@ -1145,7 +1146,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_NoStampsFallsBackToLegacyTic }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert — legacy fallback now applies since no stamp pairs are populated. Assert.That(result, Is.EqualTo(15)); @@ -1170,7 +1171,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(3)); @@ -1195,7 +1196,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_SumsPause1AndPause11() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(10)); @@ -1219,7 +1220,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_SumsPause2AndPause20() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(13)); @@ -1241,7 +1242,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_Pause3SingleSlot() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(9)); @@ -1262,7 +1263,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_NoStamps_Pause1Id2_ReturnsFi }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(5)); @@ -1279,7 +1280,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_EverythingEmptyReturnsZero() var pr = new PlanRegistration(); // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(0)); @@ -1308,7 +1309,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_ZeroDurationStampDoesNotFall }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert — stamps were observed, so the (0-min) stamp result wins // over the legacy fallback that would otherwise return 15. @@ -1334,7 +1335,7 @@ public void AggregatePauseMinutes_OneMinuteInterval_NegativeDurationStampDoesNot }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); // Assert Assert.That(result, Is.EqualTo(0)); @@ -1360,7 +1361,7 @@ public void AggregatePauseMinutes_LegacyMode_CompleteStampWins_OverLegacyTicks() }; // Act - var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + var result = FlexChain.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); // Assert — complete stamp wins: 12:00→12:03 floors to 12:00→12:00 = 0 min. Assert.That(result, Is.EqualTo(0)); @@ -1387,7 +1388,7 @@ public void ComputeNettoSecondsFromDateTimeShifts_RoundMinutes_LegacySlotPath_Re Stop1StoppedAt = null, Start1Id = 97, Stop1Id = 121, Pause1Id = 0, }; - var netto = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); + var netto = FlexChain.ComputeNettoSecondsFromDateTimeShifts(pr); Assert.That(netto, Is.EqualTo(7200), "Legacy slot math: (121-97)*5 = 120 min = 7200 s"); } @@ -1408,7 +1409,7 @@ public void ComputeNettoSecondsFromDateTimeShifts_Exact_08_04_to_10_10_Returns75 Stop1StoppedAt = new DateTime(2026, 5, 15, 10, 10, 0), Start1Id = 97, Stop1Id = 122, Pause1Id = 0, }; - var netto = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); + var netto = FlexChain.ComputeNettoSecondsFromDateTimeShifts(pr); Assert.That(netto, Is.EqualTo(7560), "08:04→10:10 = 2h06m = 7560 s (precise DateTime delta wins over legacy slot math)"); } @@ -1515,7 +1516,7 @@ public void ApplyNettoFlexChainDecimal_ClearsPreviouslyNonZeroSecondsColumns() }; var pre = new PlanRegistration { Date = new DateTime(2026, 8, 26), SumFlexEnd = 3.61 }; - PlanRegistrationHelper.ApplyNettoFlexChainDecimal(pr, pre); + FlexChain.ApplyNettoFlexChainDecimal(pr, pre); Assert.Multiple(() => { @@ -1541,7 +1542,7 @@ public void ApplyNettoFlexChainDecimal_NoPredecessor_StartsAtZero() SumFlexEndInSeconds = 67890 }; - PlanRegistrationHelper.ApplyNettoFlexChainDecimal(pr, null); + FlexChain.ApplyNettoFlexChainDecimal(pr, null); Assert.Multiple(() => { @@ -1569,7 +1570,7 @@ public void ApplyNettoFlexChainDecimal_OverrideActive_UsesTheOverride() PaiedOutFlex = 0 }; - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( pr, new PlanRegistration { SumFlexEnd = 2.0 }); Assert.Multiple(() => @@ -1593,7 +1594,7 @@ public void ClearSumFlexSeconds_ZeroesBothColumnsAndTouchesNothingElse() PaiedOutFlexInSeconds = 555 }; - PlanRegistrationHelper.ClearSumFlexSeconds(pr); + FlexChain.ClearSumFlexSeconds(pr); Assert.Multiple(() => { @@ -1631,7 +1632,7 @@ public void MixedModeChain_FiveMinuteThenOneMinute_CarriesTheDecimalBalance() }; var dayZero = new PlanRegistration { Date = new DateTime(2026, 8, 26), SumFlexEnd = 2.0 }; - PlanRegistrationHelper.ApplyNettoFlexChainDecimal(dayOne, dayZero); + FlexChain.ApplyNettoFlexChainDecimal(dayOne, dayZero); var dayTwo = new PlanRegistration { @@ -1642,7 +1643,7 @@ public void MixedModeChain_FiveMinuteThenOneMinute_CarriesTheDecimalBalance() PlanHoursInSeconds = 28800 }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( dayTwo, dayOne, preIsOneMinute: false); Assert.Multiple(() => @@ -1681,7 +1682,7 @@ public void OneMinutePredecessor_StillSeedsFromItsSecondsColumn() PlanHoursInSeconds = 28800 }; - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(pr, pre, preIsOneMinute: true); + FlexChain.ApplyNettoFlexChainSecondPrecision(pr, pre, preIsOneMinute: true); Assert.Multiple(() => { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs index 42ade928..5707fd4c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -10,6 +10,7 @@ using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.EformAngularFrontendBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site; using SdkSiteTag = Microting.eForm.Infrastructure.Data.Entities.SiteTag; @@ -515,7 +516,7 @@ public async Task Update_AdminEditsPauseTotal_OverridesSlotSum_NonDestructive() // Sanity: pre-edit the slot sum is 30 min. Assert.That( - PlanRegistrationHelper.ComputeShiftPauseSeconds(planning, 1, false), + FlexChain.ComputeShiftPauseSeconds(planning, 1, false), Is.EqualTo(30 * 60), "Pre-condition: the three sub-slots must sum to 30 min."); @@ -550,7 +551,7 @@ public async Task Update_AdminEditsPauseTotal_OverridesSlotSum_NonDestructive() Assert.That(reloaded.Pause1OverrideMinutes, Is.EqualTo(10), "Admin edit must set the per-shift override to 10 min."); Assert.That( - PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + FlexChain.ComputeShiftPauseSeconds(reloaded, 1, false), Is.EqualTo(10 * 60), "ComputeShiftPauseSeconds must honor the override, not sum slots."); Assert.That(reloaded.NettoHoursInSeconds, Is.EqualTo(28800 - 600), @@ -641,7 +642,7 @@ public async Task Update_EditOnlyStartStop_DoesNotSetOverride() Assert.That(reloaded.Pause1OverrideMinutes, Is.Null, "Unchanged pause total must not lock an override."); Assert.That( - PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + FlexChain.ComputeShiftPauseSeconds(reloaded, 1, false), Is.EqualTo(30 * 60), "Pause still computed from the recorded slots (30 min)."); } @@ -712,7 +713,7 @@ public async Task Update_ClearPause_SetsZeroOverride_NonDestructive() Assert.That(reloaded.Pause1OverrideMinutes, Is.EqualTo(0), "Clearing the pause sets an explicit zero override."); Assert.That( - PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + FlexChain.ComputeShiftPauseSeconds(reloaded, 1, false), Is.EqualTo(0), "Effective pause is zero."); Assert.That(reloaded.NettoHoursInSeconds, Is.EqualTo(28800), diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs index 9d188175..18681748 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.Logging; +using Microting.TimePlanningBase.Infrastructure.Helpers; using NSubstitute; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index 8a1206d5..54a8a3fd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -18,6 +18,7 @@ using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -457,7 +458,7 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn site.MicrotingUid.Value, out var siteTimeline) && !siteTimeline.WasOneMinuteForRow(planRegistration)) { - PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration); + FlexChain.ClearSumFlexSeconds(planRegistration); } await planRegistration.Update(dbContext); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs deleted file mode 100644 index f52a3c05..00000000 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs +++ /dev/null @@ -1,316 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Microting.TimePlanningBase.Infrastructure.Data; -using AssignedSite = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; -using PlanRegistration = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; - -namespace TimePlanning.Pn.Infrastructure.Helpers; - -/// -/// Reconstructs the history of an AssignedSite's UseOneMinuteIntervals -/// flag from its AssignedSiteVersions audit rows so read/display/calc -/// paths can resolve the mode that was IN FORCE when a given row was -/// registered ("mode at registration") instead of the site's current flag. -/// -/// Why: a row registered under 5-minute mode carries tick ids as its truth; -/// a row registered under one-minute mode carries exact stamps. When a site -/// flips the flag, per-site forking silently reinterprets historical rows -/// (tick rows suddenly render/pay from raw stamps → drift in every total). -/// Discriminating on stamp-nullness does not work either, because exact -/// stamps exist on virtually all tick rows (devices record them alongside -/// the tick ids). The reliable discriminator is this timeline. -/// -/// Schema quirk (verified in prod): PnBase.MapVersion copies EVERY -/// property of the base entity onto the version row — including -/// CreatedAt, which therefore always holds the BASE entity's original -/// creation time on every version row. The actual save time of a version row -/// is its UpdatedAt (PnBase sets the base entity's UpdatedAt to -/// UtcNow immediately before mapping the version). Change points are hence -/// derived from consecutive version rows' flag values using UpdatedAt as the -/// transition instant, ordered by version row Id (insert order). -/// -/// Date granularity: comparisons are DATE-ONLY. A flag flip saved mid-day -/// governs that WHOLE day under the new value — a PlanRegistration's Date is -/// a midnight anchor with no time-of-day, so a finer resolution is not -/// representable; making the flip day take the new mode matches the -/// operational reality that the flip is done before/with the first -/// registrations the admin wants under the new mode. -/// -/// Edge cases: -/// - No version rows at all → the site's CURRENT flag for all dates. -/// - Flag already true in the earliest version row → true from the -/// beginning of time (dates before the first row exist only for rows -/// created before the audit row — same mode as at creation). -/// - Multiple toggles → interval walk over the change points; several -/// toggles on the same date → the last save wins. -/// - Divergence correction: when the entity's CURRENT flag differs from the -/// last audited version value, the flag was flipped OUTSIDE the audited -/// path (raw-SQL ops change, or a CI seed whose dump predates the column — -/// PnBase writes a version row on every API save, so a complete trail -/// always ends on the current value). The exact flip time is unknowable, -/// so the current flag takes over from the LAST audited save date — the -/// earliest possible un-audited flip point. Audited history before that -/// date is preserved; for sites flipped through the API this is a no-op. -/// -/// Authoritative override: AssignedSite.UseOneMinuteIntervalsFrom. -/// The derived timeline above is a RECONSTRUCTION; when ops (or the -/// settings save in TimeSettingService.UpdateAssignedSite) has -/// recorded the date the flag actually took effect, that stored date is the -/// truth and the reconstruction is not consulted at all: -/// UseOneMinuteIntervals && rowDate >= UseOneMinuteIntervalsFrom -/// (date-only, same granularity rule as above). A NULL column means "nothing -/// recorded" and falls through to the derived timeline, which keeps today's -/// behaviour for every site ops has not backfilled. -/// -/// Full per-row precedence (see ): -/// 1. PlanRegistration.RegisteredUnderOneMinuteIntervals — the -/// write-time marker, ground truth for rows that carry one. -/// 2. UseOneMinuteIntervalsFrom — the stored effective date. -/// 3. the AssignedSiteVersions-derived timeline. -/// -/// The class also OWNS THE WRITE SIDE of that column: -/// is what records the date when the -/// settings save flips the flag on, so the read rule and the write rule cannot -/// drift apart. -/// -/// Cost: ONE query per site (); lookups are pure -/// in-memory. Build once per site per request scope — never per row. -/// -public sealed class OneMinuteModeTimeline -{ - private readonly bool _initialValue; - - /// The site's CURRENT flag (also the effective-date verdict's value). - private readonly bool _currentFlag; - - /// - /// The authoritative date the current flag took effect, when recorded; - /// NULL means "not recorded" and the derived timeline is used instead. - /// - private readonly DateTime? _effectiveFrom; - - /// Date-only change points in save order (date, value-from-that-date). - private readonly List<(DateTime Date, bool Value)> _changePoints; - - /// - /// In-memory constructor (also used directly by unit tests). - /// must be in version-row Id (save) order; - /// pass an empty list to fall back to . - /// is the entity's CURRENT flag — the - /// no-version-rows fallback AND the divergence-correction authority (see - /// class docs): when the trail does not end on this value, the current - /// flag takes over from the last audited save date. - /// is the site's recorded - /// UseOneMinuteIntervalsFrom; when non-null it OVERRIDES the derived - /// timeline entirely (see ). - /// - internal OneMinuteModeTimeline( - bool currentFlag, - IReadOnlyList<(bool UseOneMinuteIntervals, DateTime SavedAt)> versionFlags, - DateTime? effectiveFrom = null) - { - _currentFlag = currentFlag; - _effectiveFrom = effectiveFrom; - _changePoints = new List<(DateTime, bool)>(); - - if (versionFlags == null || versionFlags.Count == 0) - { - _initialValue = currentFlag; - return; - } - - // The earliest version row's value holds from the beginning of time. - _initialValue = versionFlags[0].UseOneMinuteIntervals; - var current = _initialValue; - foreach (var (value, savedAt) in versionFlags) - { - if (value == current) - { - continue; - } - current = value; - _changePoints.Add((savedAt.Date, current)); - } - - // Divergence correction: an audit trail written by PnBase always ends - // on the entity's current value; when it doesn't, the flag was flipped - // outside the audited path (raw-SQL ops change / legacy seed). Trust - // the CURRENT flag from the last audited save date — the earliest - // possible un-audited flip point — appended LAST so it wins over an - // audited toggle on that same date (see WasOneMinuteAt walk order). - if (current != currentFlag) - { - _changePoints.Add((versionFlags[^1].SavedAt.Date, currentFlag)); - } - } - - /// - /// Builds the timeline for one AssignedSite with a single - /// AssignedSiteVersions query. An unsaved entity (Id == 0) or a site - /// without audit rows yields a constant timeline of the current flag; - /// a null site (no AssignedSite row for the worker) yields a constant - /// 5-minute timeline, so callers never need their own empty-timeline - /// fallback. - /// - public static async Task BuildAsync( - TimePlanningPnDbContext dbContext, AssignedSite? assignedSite) - { - if (assignedSite == null) - { - return new OneMinuteModeTimeline(false, Array.Empty<(bool, DateTime)>()); - } - - var versionFlags = await dbContext.AssignedSiteVersions - .AsNoTracking() - .Where(x => x.AssignedSiteId == assignedSite.Id) - .OrderBy(x => x.Id) - // UpdatedAt is the save time of the version row (see class docs); - // CreatedAt (a copy of the base entity's creation time) is the - // stand-in for legacy rows whose UpdatedAt is NULL. - .Select(x => new { x.UseOneMinuteIntervals, x.UpdatedAt, x.CreatedAt }) - .ToListAsync(); - - return new OneMinuteModeTimeline( - assignedSite.UseOneMinuteIntervals, - versionFlags - .Select(x => (x.UseOneMinuteIntervals, x.UpdatedAt ?? x.CreatedAt)) - .ToList(), - assignedSite.UseOneMinuteIntervalsFrom); - } - - /// - /// The ONE place the stored effective date is turned into a verdict. - /// Returns null when nothing is recorded ( - /// is NULL) so the caller falls through to the derived timeline; otherwise - /// the flag applies only from that date onwards. DATE-ONLY comparison — a - /// PlanRegistration.Date is a midnight anchor with no time-of-day, - /// matching the timeline's own granularity rule. - /// - internal static bool? ResolveByEffectiveDate( - bool currentFlag, DateTime? effectiveFrom, DateTime rowDate) - => effectiveFrom == null - ? null - : currentFlag && rowDate.Date >= effectiveFrom.Value.Date; - - /// - /// Records WHEN one-minute intervals took effect, on the false→true - /// transition only. Must be called BEFORE the caller ORs the incoming value - /// into the stored flag: UseOneMinuteIntervals is deliberately - /// one-way (commit 994c9cd4), so after the OR a real transition is - /// indistinguishable from "was already true". - /// - /// The UseOneMinuteIntervalsFrom == null guard is required: an ops - /// backfill of recovered historical dates must not be clobbered with - /// today's date by an unrelated later settings save. The column is ops-only - /// — written by script or by this stamp, never exposed on a DTO. - /// - public static void StampEffectiveDateOnEnable( - AssignedSite dbAssignedSite, bool incomingUseOneMinuteIntervals, DateTime now) - { - if (!dbAssignedSite.UseOneMinuteIntervals - && incomingUseOneMinuteIntervals - && dbAssignedSite.UseOneMinuteIntervalsFrom == null) - { - dbAssignedSite.UseOneMinuteIntervalsFrom = now; - } - } - - /// - /// Resolves the mode for ONE row under the full precedence (write-time - /// marker → stored effective date → derived timeline), querying - /// AssignedSiteVersions only when neither of the first two can answer. - /// Use this from calc paths that hold a single row; loops that already - /// build a timeline should use , which - /// carries the same precedence in memory. - /// - /// NEVER call this in a loop: on a legacy row of an un-backfilled site it - /// falls through to , so a per-row call is the - /// exact N+1 this class exists to avoid. Build a timeline once instead. - /// - public static async Task ResolveRowModeAsync( - TimePlanningPnDbContext dbContext, AssignedSite? assignedSite, PlanRegistration row) - { - if (row.RegisteredUnderOneMinuteIntervals.HasValue) - { - return row.RegisteredUnderOneMinuteIntervals.Value; - } - - if (assignedSite == null) - { - return false; - } - - var byEffectiveDate = ResolveByEffectiveDate( - assignedSite.UseOneMinuteIntervals, assignedSite.UseOneMinuteIntervalsFrom, row.Date); - if (byEffectiveDate.HasValue) - { - return byEffectiveDate.Value; - } - - var timeline = await BuildAsync(dbContext, assignedSite); - return timeline.WasOneMinuteAt(row.Date); - } - - /// - /// THE definition of the per-row precedence: the write-time marker when the - /// row carries one, else this timeline (effective date, else the audit - /// trail). Every call site resolving a row's mode against a prebuilt - /// timeline goes through here rather than spelling the ?? out again. - /// Pure in-memory — safe inside a loop. - /// - public bool WasOneMinuteForRow(PlanRegistration row) - => row.RegisteredUnderOneMinuteIntervals ?? WasOneMinuteAt(row.Date); - - /// - /// for a row that may be null (typically - /// the preceding day, which does not exist for the first registration), - /// yielding null so callers can forward the result straight into the - /// "unknown mode" parameter of the flex-chain helpers. - /// - public bool? WasOneMinuteFor(PlanRegistration? row) - => row == null ? null : WasOneMinuteForRow(row); - - /// - /// for a row that may be null (typically - /// the preceding day, which does not exist for the first registration). - /// Same N+1 warning: never call this in a loop — build a timeline once and - /// use instead. - /// - public static async Task ResolveRowModeOrNullAsync( - TimePlanningPnDbContext dbContext, AssignedSite? assignedSite, PlanRegistration? row) - => row == null - ? null - : await ResolveRowModeAsync(dbContext, assignedSite, row); - - /// - /// The UseOneMinuteIntervals value in force on - /// (date-only comparison; the time component is ignored). The site's - /// recorded UseOneMinuteIntervalsFrom wins when present; only when - /// nothing is recorded does the AssignedSiteVersions-derived walk answer. - /// - public bool WasOneMinuteAt(DateTime rowDate) - { - var byEffectiveDate = ResolveByEffectiveDate(_currentFlag, _effectiveFrom, rowDate); - if (byEffectiveDate.HasValue) - { - return byEffectiveDate.Value; - } - - var date = rowDate.Date; - var value = _initialValue; - // Walk ALL change points in save order (no early break): the LAST save - // whose date is on/before the row's date wins, which stays correct even - // if UpdatedAt values are not strictly monotonic across version rows. - foreach (var (changeDate, newValue) in _changePoints) - { - if (changeDate <= date) - { - value = newValue; - } - } - return value; - } -} 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 d929b29a..41c86a7c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -12,6 +12,7 @@ using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Models.Holiday; using TimePlanning.Pn.Infrastructure.Models.Planning; @@ -363,301 +364,6 @@ public static void RecalculatePlanHoursFromShifts(PlanRegistration pr, bool useO RecalculatePlanHoursFromShifts(pr); } - /// - /// Phase 2 — second-precision NettoHours computation. - /// - /// When is on, this helper - /// computes NettoHours from DateTime deltas (precise to the second) instead - /// of the legacy (StopId - StartId - (PauseId-1)) * 5 minute-tick math - /// in the per-call sites. Mirrors the flag-off formula in seconds: - /// - /// - /// nettoSeconds = 0 - /// for each shift n in 1..5: - /// if (Start_n_StartedAt and Stop_n_StoppedAt are populated): - /// nettoSeconds += (Stop_n_StoppedAt - Start_n_StartedAt).TotalSeconds - /// if (Pause_n_StartedAt and Pause_n_StoppedAt are populated): - /// nettoSeconds -= (Pause_n_StoppedAt - Pause_n_StartedAt).TotalSeconds - /// else if (Pause_n_Id > 0): - /// nettoSeconds -= (Pause_n_Id - 1) * 5 * 60 - /// else if (Stop_n_Id >= Start_n_Id and Stop_n_Id != 0): - /// // legacy fallback for shifts that don't have DateTime stamps - /// nettoSeconds += (Stop_n_Id - Start_n_Id) * 5 * 60 - /// nettoSeconds -= (Pause_n_Id > 0 ? Pause_n_Id - 1 : 0) * 5 * 60 - /// - /// - /// Returns the computed netto seconds. The caller writes both the - /// *InSeconds primary and back-derives the legacy double - /// hour field (x = xInSeconds / 3600.0) for read compatibility. - /// - public static long ComputeNettoSecondsFromDateTimeShifts(PlanRegistration pr) - { - long nettoSeconds = 0; - - // Helper: compute one shift's contribution. Prefer DateTime delta when - // both stamps are populated; otherwise fall back to the legacy 5-min - // tick math so mixed-precision rows (some shifts precise, some not) - // still get a complete total. Pause is the canonical per-shift total - // (ALL slots, not just the primary) — second-precision because this - // method only runs on UseOneMinuteIntervals sites. - long ShiftSeconds(int shift, DateTime? startAt, DateTime? stopAt, int startId, int stopId) - { - long workSeconds; - if (startAt.HasValue && stopAt.HasValue && stopAt.Value > startAt.Value) - { - workSeconds = (long)(stopAt.Value - startAt.Value).TotalSeconds; - } - else if (stopId >= startId && stopId != 0) - { - workSeconds = (long)(stopId - startId) * 5 * 60; - } - else - { - return 0; - } - - long pauseSeconds = ComputeShiftPauseSeconds(pr, shift, useOneMinuteIntervals: true); - - return workSeconds - pauseSeconds; - } - - nettoSeconds += ShiftSeconds(1, pr.Start1StartedAt, pr.Stop1StoppedAt, pr.Start1Id, pr.Stop1Id); - nettoSeconds += ShiftSeconds(2, pr.Start2StartedAt, pr.Stop2StoppedAt, pr.Start2Id, pr.Stop2Id); - nettoSeconds += ShiftSeconds(3, pr.Start3StartedAt, pr.Stop3StoppedAt, pr.Start3Id, pr.Stop3Id); - nettoSeconds += ShiftSeconds(4, pr.Start4StartedAt, pr.Stop4StoppedAt, pr.Start4Id, pr.Stop4Id); - nettoSeconds += ShiftSeconds(5, pr.Start5StartedAt, pr.Stop5StoppedAt, pr.Start5Id, pr.Stop5Id); - - return Math.Max(0, nettoSeconds); - } - - /// - /// Aggregates total pause minutes for a PlanRegistration by summing the - /// canonical per-shift pause () across - /// shifts 1-5 and rounding the total down to whole minutes. - /// - /// ComputeShiftPauseSeconds is the single source of truth: per shift it walks - /// every populated pause slot (primary Pause{N} plus the multi-pause sub-slots) - /// and applies the exact stamp delta when useOneMinuteIntervals is true, or the - /// floor-to-5-minute clock-tick delta when it is false, falling back per shift to - /// the legacy Pause{N}Id tick value only when that shift has no timestamped slots. - /// - public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIntervals) - { - // Sum the canonical per-shift pause across all 5 shifts. The canonical - // method walks EVERY populated slot of each shift (primary + sub-slots), - // applies the exact delta (flag on) or the floor-to-5-minute clock-tick - // delta (flag off), and falls back per-shift to the legacy Pause{N}Id - // tick value only when that shift has no timestamped slots. - long totalSeconds = 0; - for (var shift = 1; shift <= 5; shift++) - { - totalSeconds += ComputeShiftPauseSeconds(pr, shift, useOneMinuteIntervals); - } - - return (int)(totalSeconds / 60); // round down to whole minutes - } - - /// - /// Reads an *InSeconds column, falling back to its legacy - /// double hour sibling when the column is still 0. - /// - /// Every *InSeconds column was added by a migration with - /// defaultValue: 0 and NO backfill (SumFlexEndInSeconds by - /// 20260108054344), so on the overwhelming majority of historical rows the - /// column reads 0 while the real value lives in the decimal. Taking the - /// column at face value silently substitutes zero for a real balance. - /// - /// A genuine zero and an unbackfilled zero are indistinguishable, which is - /// harmless: both fall back to the decimal, and a genuinely-zero row has a - /// zero decimal too. - /// - public static int SecondsOrDecimalFallback(int seconds, double hours) - => seconds != 0 ? seconds : (int)Math.Round(hours * 3600); - - /// - /// Seeds the running flex chain from the preceding day's closing balance, - /// in seconds, via ; 0 when there is - /// no preceding row. - /// - /// The fallback is load-bearing at a one-minute mode transition: the first - /// post-switch row seeds from the last PRE-switch row, which by definition - /// only ever had the decimal columns written. - /// - /// - /// The mode the PRECEDING row resolves to (write-time marker, else the - /// site's effective date / audit timeline). Pass false and the - /// row's SumFlexEndInSeconds column is IGNORED — a five-minute row - /// carries its balance in the decimal only, so a non-zero seconds column - /// there is stale residue from an earlier one-minute write, never a - /// balance. Pass null (the default) when the mode is not known and - /// the column is taken at face value, as before. - /// - /// Defence in depth. now clears - /// both seconds columns on every five-minute write, so a row written by - /// THIS version of the code cannot carry stale seconds; rows written by an - /// older version, by the background service (which never touches the - /// seconds columns) or by a direct DB edit still can, and seeding the chain - /// from such a value is what restated whole balances at a mode boundary. - /// - public static int SumFlexEndSecondsWithFallback( - PlanRegistration? preTimePlanning, bool? preIsOneMinute = null) - { - if (preTimePlanning == null) - { - return 0; - } - - if (preIsOneMinute == false) - { - return (int)Math.Round(preTimePlanning.SumFlexEnd * 3600); - } - - return SecondsOrDecimalFallback( - preTimePlanning.SumFlexEndInSeconds, preTimePlanning.SumFlexEnd); - } - - /// - /// Clears the second-precision SumFlex columns. - /// - /// The invariant: a row whose balance was last written in FIVE-MINUTE - /// (decimal) mode carries NO seconds — SumFlexStartInSeconds and - /// SumFlexEndInSeconds read 0, and every reader therefore falls back - /// to the decimal via . Leaving a - /// previous one-minute write's value behind makes the row claim a balance - /// it no longer has, and the next row seeds the whole chain from it. - /// - public static void ClearSumFlexSeconds(PlanRegistration pr) - { - pr.SumFlexStartInSeconds = 0; - pr.SumFlexEndInSeconds = 0; - } - - /// - /// The FIVE-MINUTE counterpart of - /// : - /// writes the legacy decimal Flex / SumFlexStart / SumFlexEnd chain AND - /// clears the *InSeconds siblings, so no call site can write one - /// without the other. - /// - /// Flex = (override ? NettoHoursOverride : NettoHours) - PlanHours - /// SumFlexStart = preTimePlanning?.SumFlexEnd ?? 0 - /// SumFlexEnd = SumFlexStart + effectiveNetto - PlanHours - PaiedOutFlex - /// - /// NettoHours is used AS-IS (callers that recompute it from the - /// five-minute tick math assign it immediately before calling). - /// - public static void ApplyNettoFlexChainDecimal( - PlanRegistration pr, PlanRegistration? preTimePlanning) - { - var effectiveNettoHours = pr.NettoHoursOverrideActive - ? pr.NettoHoursOverride - : pr.NettoHours; - - pr.Flex = effectiveNettoHours - pr.PlanHours; - pr.SumFlexStart = preTimePlanning?.SumFlexEnd ?? 0; - pr.SumFlexEnd = pr.SumFlexStart + effectiveNettoHours - pr.PlanHours - pr.PaiedOutFlex; - - ClearSumFlexSeconds(pr); - } - - /// - /// Preferred overload: seeds the chain from - /// (null when this is the first row) through - /// , so no call site can - /// accidentally seed from the raw, usually-zero SumFlexEndInSeconds - /// column and silently discard the carried-forward balance. - /// - /// - /// The preceding row's resolved mode, forwarded to - /// so a five-minute - /// predecessor seeds from its decimal balance instead of a stale seconds - /// column. Pass null when the mode is not cheaply resolvable. - /// - public static void ApplyNettoFlexChainSecondPrecision( - PlanRegistration pr, PlanRegistration? preTimePlanning, bool? preIsOneMinute = null) - => ApplyNettoFlexChainSecondPrecision( - pr, - SumFlexEndSecondsWithFallback(preTimePlanning, preIsOneMinute), - preTimePlanning != null); - - /// - /// Phase 2 — write the second-precision NettoHours / Flex / SumFlex chain. - /// - /// Computes NettoHoursInSeconds from DateTime deltas (or legacy - /// fallback) via , - /// derives FlexInSeconds from PlanHoursInSeconds, then - /// derives SumFlexEndInSeconds from the running balance plus the - /// computed flex minus paid-out flex. Back-derives the legacy - /// double hour fields (x = xInSeconds / 3600.0) so existing - /// read paths stay compatible. - /// - /// Mirrors the existing flag-off formula sign-for-sign: - /// Flex = NettoHours - PlanHours (or override) - /// SumFlexEnd = SumFlexStart + NettoHours - PlanHours - PaiedOutFlex - /// (when preTimePlanning exists) - /// SumFlexEnd = NettoHours - PlanHours - PaiedOutFlex - /// (when no preTimePlanning, SumFlexStart = 0) - /// — but every operand is in seconds, so no precision is lost on the - /// way through the int columns. - /// - /// Caller passes from the previous - /// day's SumFlexEndInSeconds (or 0 when there is no preceding row). - /// When the override is active, the override (in hours) is converted to - /// seconds via * 3600 for the chain. - /// - /// The plan registration to update in place. - /// - /// Running flex balance carried in from the previous day's - /// SumFlexEndInSeconds; pass 0 when there is no preceding row. - /// - /// - /// True when there is a preceding planning row (use the running balance); - /// false when this is the first row (reset SumFlexStart to 0). - /// - public static void ApplyNettoFlexChainSecondPrecision(PlanRegistration pr, - int sumFlexStartInSeconds, bool hasPreTimePlanning) - { - var nettoSeconds = ComputeNettoSecondsFromDateTimeShifts(pr); - pr.NettoHoursInSeconds = (int)nettoSeconds; - pr.NettoHours = nettoSeconds / 3600.0; - - // Punch-clock / scheduled days and production writers populate only the - // doubles; the *InSeconds siblings stay 0. See SecondsOrDecimalFallback. - var planHoursSeconds = SecondsOrDecimalFallback(pr.PlanHoursInSeconds, pr.PlanHours); - var paiedOutFlexSeconds = - SecondsOrDecimalFallback(pr.PaiedOutFlexInSeconds, pr.PaiedOutFlex); - - // Mirror the flag-off override semantics: - // Flex = (override ? NettoHoursOverride : NettoHours) - PlanHours - // SumFlexEnd uses the same numerator. - var effectiveNettoSecondsForFlex = pr.NettoHoursOverrideActive - ? (long)(pr.NettoHoursOverride * 3600) - : nettoSeconds; - - var flexSeconds = effectiveNettoSecondsForFlex - planHoursSeconds; - pr.FlexInSeconds = (int)flexSeconds; - pr.Flex = flexSeconds / 3600.0; - - if (hasPreTimePlanning) - { - pr.SumFlexStartInSeconds = sumFlexStartInSeconds; - pr.SumFlexStart = sumFlexStartInSeconds / 3600.0; - var sumFlexEndSeconds = (long)sumFlexStartInSeconds - + effectiveNettoSecondsForFlex - planHoursSeconds - - paiedOutFlexSeconds; - pr.SumFlexEndInSeconds = (int)sumFlexEndSeconds; - pr.SumFlexEnd = sumFlexEndSeconds / 3600.0; - } - else - { - pr.SumFlexStartInSeconds = 0; - pr.SumFlexStart = 0; - var sumFlexEndSeconds = effectiveNettoSecondsForFlex - planHoursSeconds - paiedOutFlexSeconds; - pr.SumFlexEndInSeconds = (int)sumFlexEndSeconds; - pr.SumFlexEnd = sumFlexEndSeconds / 3600.0; - } - } - public static async Task UpdatePlanRegistrationsInPeriod( List planningsInPeriod, TimePlanningPlanningModel siteModel, @@ -796,13 +502,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // flag — see OneMinuteModeTimeline for why. if (rowIsOneMinute) { - ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, oneMinuteTimeline.WasOneMinuteFor(preTimePlanning)); } else { - ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); + FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } await planRegistration.Update(dbContext).ConfigureAwait(false); @@ -1088,13 +794,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // flag — see OneMinuteModeTimeline for why. if (rowIsOneMinute) { - ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, oneMinuteTimeline.WasOneMinuteFor(preTimePlanning)); } else { - ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); + FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } await planRegistration.Update(dbContext).ConfigureAwait(false); } @@ -1351,7 +1057,7 @@ await dbContext.PlanRegistrations.AsNoTracking() Pause5StoppedAt = planRegistration.Pause5StoppedAt }; - planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, rowIsOneMinute); + planningModel.PauseMinutes += FlexChain.AggregatePauseMinutes(planRegistration, rowIsOneMinute); // planningModel.PauseMinutes = planningModel.PauseMinutes > 0 ? planningModel.PauseMinutes - 5 : 0; @@ -1481,13 +1187,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // flag — see OneMinuteModeTimeline for why. if (rowIsOneMinute) { - ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, oneMinuteTimeline.WasOneMinuteFor(preTimePlanning)); } else { - ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); + FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } await planRegistration.Update(dbContext).ConfigureAwait(false); @@ -1761,13 +1467,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // flag — see OneMinuteModeTimeline for why. if (rowIsOneMinute) { - ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, oneMinuteTimeline.WasOneMinuteFor(preTimePlanning)); } else { - ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); + FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } // Console.WriteLine($"The plannedHours are now: {planRegistration.PlanHours}"); @@ -2133,124 +1839,6 @@ public static async Task ReadBySiteAndDate( } } - /// - /// The legacy 5-minute-tick integer pause field for a shift's primary slot. - /// Pause{N}Id stores break in 5-minute ticks plus a +1 sentinel - /// (Pause1Id = 1 means 0 min, Pause1Id = 4 means 15 min, etc.). - /// - private static int PrimaryPauseId(PlanRegistration pr, int shift) => shift switch - { - 1 => pr.Pause1Id, - 2 => pr.Pause2Id, - 3 => pr.Pause3Id, - 4 => pr.Pause4Id, - 5 => pr.Pause5Id, - _ => 0 - }; - - private static readonly long FiveMinuteTicks = TimeSpan.FromMinutes(5).Ticks; - - /// - /// Floors a DateTime down to its absolute 5-minute grid boundary on the - /// timeline (NOT relative to the day) so the result is over-midnight safe. - /// - private static DateTime FloorTo5Min(DateTime dt) - => new DateTime(dt.Ticks - (dt.Ticks % FiveMinuteTicks), dt.Kind); - - /// - /// Canonical per-shift pause total in SECONDS — the single source of truth - /// for every netto and display pause computation. - /// - /// Sums the contribution of EVERY populated pause slot that belongs to the - /// shift (primary Pause{N} plus its sub-slots, see - /// ), where each slot contributes: - /// • == true → the exact - /// (StoppedAt - StartedAt) delta in seconds (full precision). - /// • == false → the clock-tick - /// delta: floor BOTH endpoints to the absolute 5-minute grid and - /// difference them — floor(stop) - floor(start), a whole number of - /// 5-minute units. A pause that stays inside one 5-min cell contributes - /// 0; it adds 5 min for each 5-minute boundary it crosses. - /// - /// Fallback: when the shift has NO slot with both timestamps present (e.g. - /// legacy admin-entered rows that only carry the integer field), falls back - /// to the legacy 5-minute-tick value of the shift's primary slot only: - /// (Pause{N}Id > 0 ? Pause{N}Id - 1 : 0) * 5 * 60 seconds. - /// - public static int ComputeShiftPauseSeconds(PlanRegistration r, int shift, bool useOneMinuteIntervals) - { - // Admin/manual pause override takes precedence: when set, it is the - // authoritative total pause MINUTES for the shift. The recorded - // Pause{N}StartedAt/StoppedAt sub-slots are preserved untouched in the DB - // (documentation of what the worker actually did) but are not summed here. - var overrideMinutes = GetShiftPauseOverrideMinutes(r, shift); - if (overrideMinutes.HasValue) - { - return overrideMinutes.Value * 60; - } - - long totalSeconds = 0; - var hasTimestampedSlot = false; - - foreach (var (startedAt, stoppedAt) in EnumerateShiftPauseStampPairs(r, shift)) - { - // A slot only counts as "measured" — and thus suppresses the - // legacy-tick fallback — when BOTH endpoints are present, i.e. it is - // a complete, measurable interval. A deliberately zero-duration - // (start == stop) or invalid (stop < start) but COMPLETE pause still - // counts: the worker stamped a real (if zero) pause, so the intended - // contribution is 0 and the legacy field must not resurface. - // An orphaned slot (only one endpoint — e.g. kiosk crash or partial - // edit) is NOT a complete slot, so it does not suppress the fallback; - // the row correctly falls back to the legacy Pause{N}Id tick value. - if (startedAt.HasValue && stoppedAt.HasValue) - { - hasTimestampedSlot = true; - } - - if (!startedAt.HasValue || !stoppedAt.HasValue || stoppedAt.Value <= startedAt.Value) - { - continue; - } - - if (useOneMinuteIntervals) - { - totalSeconds += (long)(stoppedAt.Value - startedAt.Value).TotalSeconds; - } - else - { - var tickDelta = FloorTo5Min(stoppedAt.Value) - FloorTo5Min(startedAt.Value); - totalSeconds += (long)tickDelta.TotalSeconds; - } - } - - if (!hasTimestampedSlot) - { - var pauseId = PrimaryPauseId(r, shift); - return pauseId > 0 ? (pauseId - 1) * 5 * 60 : 0; - } - - return (int)totalSeconds; - } - - /// - /// Read the per-shift admin/manual pause override (in minutes) from the - /// registration. null = no override (compute pause from recorded slots); - /// non-null = authoritative total pause minutes for that shift. - /// - public static int? GetShiftPauseOverrideMinutes(PlanRegistration r, int shift) - { - return shift switch - { - 1 => r.Pause1OverrideMinutes, - 2 => r.Pause2OverrideMinutes, - 3 => r.Pause3OverrideMinutes, - 4 => r.Pause4OverrideMinutes, - 5 => r.Pause5OverrideMinutes, - _ => null - }; - } - /// /// Set the per-shift admin/manual pause override (in minutes) on the /// registration. null reverts to compute-from-slots. @@ -2286,7 +1874,7 @@ public static void ProjectPauseOverridesOntoDto( { for (var shift = 1; shift <= 5; shift++) { - var overrideMinutes = GetShiftPauseOverrideMinutes(source, shift); + var overrideMinutes = FlexChain.GetShiftPauseOverrideMinutes(source, shift); // Surface the raw override on the DTO regardless (web dialog read). SetDtoPauseOverrideMinutes(model, shift, overrideMinutes); @@ -2327,7 +1915,7 @@ public static void ProjectPauseOverridesOntoWorkingHours( { for (var shift = 1; shift <= 5; shift++) { - var overrideMinutes = GetShiftPauseOverrideMinutes(source, shift); + var overrideMinutes = FlexChain.GetShiftPauseOverrideMinutes(source, shift); if (!overrideMinutes.HasValue) { continue; @@ -2625,7 +2213,7 @@ public static void ComputeTimeTrackingFields(PlanRegistration planRegistration) long totalPauseSeconds = 0; for (var shift = 1; shift <= 5; shift++) { - totalPauseSeconds += ComputeShiftPauseSeconds(planRegistration, shift, useOneMinuteIntervals: false); + totalPauseSeconds += FlexChain.ComputeShiftPauseSeconds(planRegistration, shift, useOneMinuteIntervals: false); } // Net work seconds = total work - total pause (cannot be negative) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs index bbccd6cc..efda3848 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs @@ -42,6 +42,7 @@ namespace TimePlanning.Pn.Services.TimePlanningFlexService; using Microting.eFormApi.BasePn.Infrastructure.Models.Common; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Microting.TimePlanningBase.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanningLocalizationService; @@ -260,7 +261,7 @@ private async Task UpdatePlanning(PlanRegistration planRegistration, // updating the legacy double below would leave that column stale, so keep it // in lockstep here too. Old value falls back to the double the same way those // chains do, since this row may itself have only ever had the double set. - var oldPaiedOutFlexSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback( + var oldPaiedOutFlexSeconds = FlexChain.SecondsOrDecimalFallback( planRegistration.PaiedOutFlexInSeconds, planRegistration.PaiedOutFlex); var newPaiedOutFlexSeconds = (int)Math.Round(model.PaidOutFlex * 3600); @@ -279,7 +280,7 @@ private async Task UpdatePlanning(PlanRegistration planRegistration, // fallback would read exactly the stale column this change exists to // distrust, so do not read it at all there. var oldSumFlexEndSeconds = rowIsOneMinute - ? PlanRegistrationHelper.SumFlexEndSecondsWithFallback(planRegistration) + ? FlexChain.SumFlexEndSecondsWithFallback(planRegistration) : 0; planRegistration.SumFlexEnd += planRegistration.PaiedOutFlex - model.PaidOutFlex; @@ -292,7 +293,7 @@ private async Task UpdatePlanning(PlanRegistration planRegistration, else { // Five-minute row: the adjusted decimal above is the whole balance. - PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration); + FlexChain.ClearSumFlexSeconds(planRegistration); } planRegistration.PaiedOutFlex = model.PaidOutFlex; 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 5edbecf8..95fbbfad 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Text.RegularExpressions; using Microting.EformAngularFrontendBase.Infrastructure.Data; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -389,7 +390,7 @@ await innerDbContext.PlanRegistrations.AsNoTracking() // the shared decimal helper so the *InSeconds columns // are written (as 0) by the same call that writes the // decimals — the two can never drift apart. - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( newPlanRegistration, preTimePlanning); } @@ -601,7 +602,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // constructed row, so this only carries the predecessor's // closing balance forward — through the shared helper so the // seconds columns cannot drift from the decimals. - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( newPlanRegistration, preTimePlanning); } @@ -1060,14 +1061,14 @@ await dbContext.PlanRegistrations.AsNoTracking() // legacy double hour fields. if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planning, preTimePlanning, cascadeTimeline.WasOneMinuteFor(preTimePlanning)); } else { planning.NettoHours = hours; - PlanRegistrationHelper.ApplyNettoFlexChainDecimal(planning, preTimePlanning); + FlexChain.ApplyNettoFlexChainDecimal(planning, preTimePlanning); } // Ensure timestamps are populated from IDs for accurate time tracking calculation @@ -1113,13 +1114,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // AT REGISTRATION — see OneMinuteModeTimeline. if (cascadeTimeline.WasOneMinuteForRow(planningAfterThisPlanning)) { - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planningAfterThisPlanning, preTimePlanningAfterThisPlanning, cascadeTimeline.WasOneMinuteFor(preTimePlanningAfterThisPlanning)); } else { - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( planningAfterThisPlanning, preTimePlanningAfterThisPlanning); } @@ -1406,7 +1407,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // legacy double hour fields. if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planning, preTimePlanning, cascadeTimeline.WasOneMinuteFor(preTimePlanning)); } @@ -1431,7 +1432,7 @@ await dbContext.PlanRegistrations.AsNoTracking() planning.Flex = planning.NettoHours - planning.PlanHours; // The forensic zero: this row's balance now lives in the decimals // only, so a previous one-minute write's seconds must not survive. - PlanRegistrationHelper.ClearSumFlexSeconds(planning); + FlexChain.ClearSumFlexSeconds(planning); } // Ensure timestamps are populated from IDs for accurate time tracking calculation @@ -1466,13 +1467,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // AT REGISTRATION — see OneMinuteModeTimeline. if (cascadeTimeline.WasOneMinuteForRow(planningAfterThisPlanning)) { - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planningAfterThisPlanning, preTimePlanningAfterThisPlanning, cascadeTimeline.WasOneMinuteFor(preTimePlanningAfterThisPlanning)); } else { - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( planningAfterThisPlanning, preTimePlanningAfterThisPlanning); } @@ -1717,7 +1718,7 @@ internal static int[] CaptureCurrentShiftShownTicks(PlanRegistration planning) var ticks = new int[6]; // index 0 unused; shifts are 1..5 for (var shift = 1; shift <= 5; shift++) { - var overrideMinutes = PlanRegistrationHelper.GetShiftPauseOverrideMinutes(planning, shift); + var overrideMinutes = FlexChain.GetShiftPauseOverrideMinutes(planning, shift); ticks[shift] = overrideMinutes.HasValue ? (overrideMinutes.Value / 5) + 1 : GetShiftPauseId(planning, shift); @@ -1850,7 +1851,7 @@ private static double ComputePlanningNettoMinutes(PlanRegistration planning, boo // Admin/manual pause override wins: when set, it is the authoritative // total pause MINUTES for the shift, replacing both the one-minute // timestamp delta and the legacy (Pause{N}Id-1)*5 tick deduction. - var overrideMinutes = PlanRegistrationHelper.GetShiftPauseOverrideMinutes(planning, shift); + var overrideMinutes = FlexChain.GetShiftPauseOverrideMinutes(planning, shift); if (useOneMinuteIntervals && startedAt.HasValue && stoppedAt.HasValue && stoppedAt.Value > startedAt.Value) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index 6264e62f..ed5cee6d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE #nullable enable using JetBrains.Annotations; using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index b6e23e2b..67415f70 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -271,9 +271,9 @@ public async Task>> Inde var pauseRowIsOneMinute = tp.RegisteredUnderOneMinuteIntervals ?? oneMinuteTimeline.WasOneMinuteAt(tp.Date); tp.Shift1PauseMinutes = - PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 1, pauseRowIsOneMinute) / 60; + FlexChain.ComputeShiftPauseSeconds(pauseRow, 1, pauseRowIsOneMinute) / 60; tp.Shift2PauseMinutes = - PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 2, pauseRowIsOneMinute) / 60; + FlexChain.ComputeShiftPauseSeconds(pauseRow, 2, pauseRowIsOneMinute) / 60; } } @@ -317,10 +317,10 @@ public async Task>> Inde // materialized PlanRegistration already in scope, so this reuses // ComputeShiftPauseSeconds with no extra query (no N+1). Shift1PauseMinutes = lastPlanning != null - ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 1, lastPlanningIsOneMinute) / 60 + ? FlexChain.ComputeShiftPauseSeconds(lastPlanning, 1, lastPlanningIsOneMinute) / 60 : 0, Shift2PauseMinutes = lastPlanning != null - ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 2, lastPlanningIsOneMinute) / 60 + ? FlexChain.ComputeShiftPauseSeconds(lastPlanning, 2, lastPlanningIsOneMinute) / 60 : 0, Shift3Start = lastPlanning?.Start3Id, Shift3Stop = lastPlanning?.Stop3Id, @@ -355,7 +355,7 @@ public async Task>> Inde SumFlexStartInSeconds = lastPlanningIsOneMinute ? lastPlanning?.SumFlexStartInSeconds ?? 0 : 0, - SumFlexEndInSeconds = PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + SumFlexEndInSeconds = FlexChain.SumFlexEndSecondsWithFallback( lastPlanning, lastPlanningIsOneMinute), PaiedOutFlexInSeconds = lastPlanning?.PaiedOutFlexInSeconds ?? 0, Message = lastPlanning?.MessageId, @@ -521,7 +521,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // OneMinuteModeTimeline. if (cascadeTimeline.WasOneMinuteForRow(planRegistration)) { - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, cascadeTimeline.WasOneMinuteFor(preTimePlanning)); } @@ -530,7 +530,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Flag-off path keeps the legacy double formula (it honours // NettoHoursOverrideActive, matching UpdatePlanRegistration) // and clears the seconds columns with the same call. - PlanRegistrationHelper.ApplyNettoFlexChainDecimal( + FlexChain.ApplyNettoFlexChainDecimal( planRegistration, preTimePlanning); } @@ -1041,7 +1041,7 @@ internal void ApplyRunningFlexChain( // SumFlexStart when the seconds column is still 0 — otherwise // the whole carried-forward balance is dropped at the head of // the chain. See PlanRegistrationHelper.SecondsOrDecimalFallback. - row.SumFlexStartInSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback( + row.SumFlexStartInSeconds = FlexChain.SecondsOrDecimalFallback( row.SumFlexStartInSeconds, row.SumFlexStart); row.SumFlexStart = row.SumFlexStartInSeconds / 3600.0; row.SumFlexEndInSeconds = row.SumFlexStartInSeconds @@ -1263,31 +1263,31 @@ private static double ComputeFlagOffNettoMinutes(PlanRegistration pr) if (pr.Stop1Id >= pr.Start1Id && pr.Stop1Id != 0) { nettoMinutes += (pr.Stop1Id - pr.Start1Id) * minutesMultiplier; - nettoMinutes -= PlanRegistrationHelper.ComputeShiftPauseSeconds(pr, 1, useOneMinuteIntervals: false) / 60.0; + nettoMinutes -= FlexChain.ComputeShiftPauseSeconds(pr, 1, useOneMinuteIntervals: false) / 60.0; } if (pr.Stop2Id >= pr.Start2Id && pr.Stop2Id != 0) { nettoMinutes += (pr.Stop2Id - pr.Start2Id) * minutesMultiplier; - nettoMinutes -= PlanRegistrationHelper.ComputeShiftPauseSeconds(pr, 2, useOneMinuteIntervals: false) / 60.0; + nettoMinutes -= FlexChain.ComputeShiftPauseSeconds(pr, 2, useOneMinuteIntervals: false) / 60.0; } if (pr.Stop3Id >= pr.Start3Id && pr.Stop3Id != 0) { nettoMinutes += (pr.Stop3Id - pr.Start3Id) * minutesMultiplier; - nettoMinutes -= PlanRegistrationHelper.ComputeShiftPauseSeconds(pr, 3, useOneMinuteIntervals: false) / 60.0; + nettoMinutes -= FlexChain.ComputeShiftPauseSeconds(pr, 3, useOneMinuteIntervals: false) / 60.0; } if (pr.Stop4Id >= pr.Start4Id && pr.Stop4Id != 0) { nettoMinutes += (pr.Stop4Id - pr.Start4Id) * minutesMultiplier; - nettoMinutes -= PlanRegistrationHelper.ComputeShiftPauseSeconds(pr, 4, useOneMinuteIntervals: false) / 60.0; + nettoMinutes -= FlexChain.ComputeShiftPauseSeconds(pr, 4, useOneMinuteIntervals: false) / 60.0; } if (pr.Stop5Id >= pr.Start5Id && pr.Stop5Id != 0) { nettoMinutes += (pr.Stop5Id - pr.Start5Id) * minutesMultiplier; - nettoMinutes -= PlanRegistrationHelper.ComputeShiftPauseSeconds(pr, 5, useOneMinuteIntervals: false) / 60.0; + nettoMinutes -= FlexChain.ComputeShiftPauseSeconds(pr, 5, useOneMinuteIntervals: false) / 60.0; } return nettoMinutes; @@ -1333,7 +1333,7 @@ private static void ApplyPunchClockFlexChainDecimal( planRegistration.SumFlexStart = 0; } - PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration); + FlexChain.ClearSumFlexSeconds(planRegistration); } public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model) @@ -1696,7 +1696,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Single-row save, not a loop, so resolving the predecessor here // is not an N+1 — and costs nothing once its marker or the site's // effective date can answer. - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, await OneMinuteModeTimeline.ResolveRowModeOrNullAsync( dbContext, assignedSite, preTimePlanning)); @@ -1990,7 +1990,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Single-row save, not a loop, so resolving the predecessor here // is not an N+1 — and costs nothing once its marker or the site's // effective date can answer. - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, await OneMinuteModeTimeline.ResolveRowModeOrNullAsync( dbContext, assignedSite, preTimePlanning)); @@ -2343,7 +2343,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Single-row save, not a loop, so resolving the predecessor here // is not an N+1 — and costs nothing once its marker or the site's // effective date can answer. - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, await OneMinuteModeTimeline.ResolveRowModeOrNullAsync( dbContext, assignedSite, preTimePlanning)); @@ -2626,7 +2626,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Single-row save, not a loop, so resolving the predecessor here // is not an N+1 — and costs nothing once its marker or the site's // effective date can answer. - PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( + FlexChain.ApplyNettoFlexChainSecondPrecision( planRegistration, preTimePlanning, await OneMinuteModeTimeline.ResolveRowModeOrNullAsync( dbContext, assignedSite, preTimePlanning)); @@ -4079,7 +4079,7 @@ public async Task Import(IFormFile file) // row keeps its seconds untouched here. if (!importTimeline.WasOneMinuteForRow(planRegistration)) { - PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration); + FlexChain.ClearSumFlexSeconds(planRegistration); } await planRegistration.Update(dbContext); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index 68a74405..7eb1c6b2 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj @@ -33,7 +33,7 @@ - + From da4d31a19ddee48cfd18bd5bf5ba52d425e1782c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 2 Sep 2026 15:27:34 +0200 Subject: [PATCH 2/3] chore: re-trigger CI to distinguish flake from regression From cc22c365e34a5e52a879edbae922594dd079c9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 2 Sep 2026 16:55:52 +0200 Subject: [PATCH 3/3] chore: re-trigger CI after a hung shard-d runner