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 @@ - +