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