diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs
index 9dc32705..ae9b1d79 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs
@@ -28,7 +28,12 @@ namespace TimePlanning.Pn.Test;
/// - — the per-row
/// write-time marker outranks both.
/// - — the
-/// reverse seed fallback (SumFlexEndInSeconds is 0 on ~97% of rows).
+/// reverse seed fallback (SumFlexEndInSeconds is 0 on ~97% of rows) AND its
+/// mode-aware form, which ignores a STALE non-zero seconds column on a
+/// predecessor that resolves to five-minute mode.
+/// - — the in-memory
+/// per-row resolution (marker → effective date → timeline) the chain sites
+/// use for the PRECEDING row.
/// - — the
/// false→true settings stamp and its no-clobber guard.
///
@@ -212,7 +217,16 @@ public async Task NoAssignedSite_ResolvesToFiveMinute()
[Test]
public void SeedFallback_NullPredecessor_IsZero()
{
- Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0));
+ Assert.Multiple(() =>
+ {
+ Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0));
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null, preIsOneMinute: false),
+ Is.EqualTo(0), "…whatever the mode argument says.");
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null, preIsOneMinute: true),
+ Is.EqualTo(0));
+ });
}
[Test]
@@ -248,6 +262,176 @@ public void SeedFallback_ZeroSeconds_FallsBackToTheDecimalBalance()
});
}
+ // ---------------------------------------------------------------- //
+ // 4b. STALE seconds on a five-minute predecessor //
+ // ---------------------------------------------------------------- //
+ //
+ // Observed in production (tenant 994, site 21445, effective date
+ // 2026-08-26 13:53:47):
+ //
+ // Date SumFlexStart SumFlexEnd StartInSeconds EndInSeconds marker
+ // 2026-08-27 3.61 -3.97 -263168 -290456 0
+ // 2026-08-28 -80.68 -86.01 -290456 -309644 NULL
+ //
+ // The 08-27 row is dated AFTER the site's effective date, so the date alone
+ // would resolve it to one-minute — but its write-time marker says five
+ // minute and the marker outranks the date. The five-minute branch wrote its
+ // decimals and left the seconds columns holding an older one-minute write's
+ // value, so the 08-28 row seeded from -290456 s (-80.68 h) instead of the
+ // correct decimal -3.97 h: a 76.71-hour break on a live site.
+
+ private const int StaleSeconds = -290456; // -80.68 h, the residue
+ private const double TrueDecimalHours = -3.97; // the row's real closing balance
+ private const int TrueDecimalSeconds = -14292; // Round(-3.97 * 3600)
+
+ [Test]
+ public void SeedFallback_FiveMinutePredecessor_IgnoresStaleSecondsColumn()
+ {
+ var pre = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 27),
+ SumFlexEnd = TrueDecimalHours,
+ SumFlexEndInSeconds = StaleSeconds
+ };
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ PlanRegistrationHelper.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),
+ Is.EqualTo(StaleSeconds),
+ "A one-minute predecessor keeps the seconds column as its truth.");
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre),
+ Is.EqualTo(StaleSeconds),
+ "Unknown mode keeps the pre-existing behaviour.");
+ });
+ }
+
+ [Test]
+ public void SeedFallback_FiveMinutePredecessor_WithZeroSeconds_IsUnchanged()
+ {
+ // The post-fix shape: five-minute writes clear the columns, so both
+ // rules agree and the decimal answers either way.
+ var pre = new PlanRegistration { SumFlexEnd = 12.5, SumFlexEndInSeconds = 0 };
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre, preIsOneMinute: false),
+ Is.EqualTo(45000));
+ }
+
+ ///
+ /// The production bug in miniature: the predecessor resolves to FIVE-MINUTE
+ /// via its write-time MARKER even though its date is after the site's
+ /// effective date (marker > effective date > timeline), and it carries
+ /// stale seconds that disagree with its decimal. The successor must open on
+ /// the decimal.
+ ///
+ /// A date-based test cannot catch this: by date alone the predecessor is a
+ /// one-minute row.
+ ///
+ [Test]
+ public void MarkerFiveMinutePredecessorAfterTheEffectiveDate_SeedsFromTheDecimal()
+ {
+ var timeline = new OneMinuteModeTimeline(
+ currentFlag: true,
+ versionFlags: Array.Empty<(bool, DateTime)>(),
+ effectiveFrom: new DateTime(2026, 8, 26, 13, 53, 47));
+
+ var pre = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 27),
+ RegisteredUnderOneMinuteIntervals = false, // the marker, and it wins
+ SumFlexEnd = TrueDecimalHours,
+ SumFlexEndInSeconds = StaleSeconds
+ };
+
+ Assert.That(timeline.WasOneMinuteAt(pre.Date), Is.True,
+ "By DATE alone the predecessor would look like a one-minute row…");
+ Assert.That(timeline.WasOneMinuteFor(pre), Is.False,
+ "…but its write-time marker outranks the effective date.");
+
+ // The successor: a one-minute row, 8 h worked against an 8 h plan, so it
+ // adds nothing of its own and its closing balance IS the carried seed.
+ var successor = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 28),
+ Start1StartedAt = new DateTime(2026, 8, 28, 8, 0, 0),
+ Stop1StoppedAt = new DateTime(2026, 8, 28, 16, 0, 0),
+ PlanHours = 8.0,
+ PlanHoursInSeconds = 28800
+ };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
+ successor, pre, timeline.WasOneMinuteFor(pre));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(successor.SumFlexStartInSeconds, Is.EqualTo(TrueDecimalSeconds),
+ "Opens on the predecessor's decimal balance (-3.97 h), NOT on the "
+ + "stale seconds column (-80.68 h).");
+ Assert.That(successor.SumFlexStart, Is.EqualTo(TrueDecimalHours).Within(0.001));
+ Assert.That(successor.SumFlexEndInSeconds, Is.EqualTo(TrueDecimalSeconds));
+ Assert.That(successor.SumFlexEnd, Is.EqualTo(TrueDecimalHours).Within(0.001),
+ "Pre-fix this closed at -86.01 h — a 76.71-hour break.");
+ });
+ }
+
+ ///
+ /// All four mobile/kiosk punch-clock legs call this with the preceding row,
+ /// which is null on a worker's very first registration. Null in, null out —
+ /// "mode unknown" — and no database access, which is what makes the
+ /// null! context below safe in production too.
+ ///
+ [Test]
+ public async Task ResolveRowModeOrNull_NullRow_IsNull_AndTouchesNoDbContext()
+ {
+ var site = new AssignedSite
+ {
+ UseOneMinuteIntervals = true,
+ UseOneMinuteIntervalsFrom = EffectiveFrom
+ };
+
+ // Sequential awaits rather than Assert.Multiple: an async lambda there
+ // would be async void and the assertions could escape the block.
+ Assert.That(
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(null!, site, null),
+ Is.Null);
+ Assert.That(
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(null!, null, null),
+ Is.Null, "A null site does not turn a null row into 'five-minute'.");
+ }
+
+ [Test]
+ public void WasOneMinuteFor_NullRow_IsNull_ForwardableAsUnknownMode()
+ {
+ var timeline = new OneMinuteModeTimeline(
+ currentFlag: true, versionFlags: Array.Empty<(bool, DateTime)>());
+ Assert.That(timeline.WasOneMinuteFor(null), Is.Null);
+ }
+
+ [Test]
+ public void WasOneMinuteFor_UnmarkedRow_FallsThroughToTheTimeline()
+ {
+ var timeline = new OneMinuteModeTimeline(
+ currentFlag: true,
+ versionFlags: Array.Empty<(bool, DateTime)>(),
+ effectiveFrom: EffectiveFrom);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ timeline.WasOneMinuteFor(new PlanRegistration { Date = new DateTime(2026, 5, 31) }),
+ Is.False);
+ Assert.That(
+ timeline.WasOneMinuteFor(new PlanRegistration { Date = new DateTime(2026, 6, 1) }),
+ Is.True);
+ });
+ }
+
// ---------------------------------------------------------------- //
// 5. The settings stamp //
// ---------------------------------------------------------------- //
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
index 6e4011c6..38ce7db9 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
@@ -1488,6 +1488,209 @@ public void GetDeclaredPayCodes_CollectsFromAllSources_DedupsAndSkipsEmpty_Order
Assert.That(codes, Is.EqualTo(new List { "A", "B", "C", "D", "E" }));
}
+ // ------------------------------------------------------------------ //
+ // ApplyNettoFlexChainDecimal — the FIVE-MINUTE write path //
+ // ------------------------------------------------------------------ //
+
+ ///
+ /// The invariant the whole fix rests on: a five-minute write leaves NO
+ /// seconds behind. Before this, the decimal branches wrote SumFlexStart /
+ /// SumFlexEnd and left the *InSeconds columns exactly as they were, so a
+ /// row recomputed under five-minute rules kept whatever an earlier
+ /// one-minute write had put there — and the next row seeded its whole chain
+ /// from that stale value.
+ ///
+ [Test]
+ public void ApplyNettoFlexChainDecimal_ClearsPreviouslyNonZeroSecondsColumns()
+ {
+ var pr = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 27),
+ NettoHours = 8.0,
+ PlanHours = 7.5,
+ PaiedOutFlex = 0,
+ // Residue from an earlier one-minute write of the same row.
+ SumFlexStartInSeconds = -263168,
+ SumFlexEndInSeconds = -290456
+ };
+ var pre = new PlanRegistration { Date = new DateTime(2026, 8, 26), SumFlexEnd = 3.61 };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(pr, pre);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pr.SumFlexStart, Is.EqualTo(3.61).Within(1e-9));
+ Assert.That(pr.SumFlexEnd, Is.EqualTo(4.11).Within(1e-9),
+ "3.61 + 8.0 - 7.5 - 0");
+ Assert.That(pr.Flex, Is.EqualTo(0.5).Within(1e-9));
+ Assert.That(pr.SumFlexStartInSeconds, Is.EqualTo(0),
+ "The forensic zero: a five-minute row carries no seconds.");
+ Assert.That(pr.SumFlexEndInSeconds, Is.EqualTo(0));
+ });
+ }
+
+ [Test]
+ public void ApplyNettoFlexChainDecimal_NoPredecessor_StartsAtZero()
+ {
+ var pr = new PlanRegistration
+ {
+ NettoHours = 6.0,
+ PlanHours = 8.0,
+ PaiedOutFlex = 1.0,
+ SumFlexStartInSeconds = 12345,
+ SumFlexEndInSeconds = 67890
+ };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(pr, null);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pr.SumFlexStart, Is.EqualTo(0));
+ Assert.That(pr.SumFlexEnd, Is.EqualTo(-3.0).Within(1e-9), "0 + 6 - 8 - 1");
+ Assert.That(pr.Flex, Is.EqualTo(-2.0).Within(1e-9));
+ Assert.That(pr.SumFlexStartInSeconds, Is.EqualTo(0));
+ Assert.That(pr.SumFlexEndInSeconds, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// Same override semantics as the one-minute chain: the override replaces
+ /// NettoHours in BOTH Flex and SumFlexEnd.
+ ///
+ [Test]
+ public void ApplyNettoFlexChainDecimal_OverrideActive_UsesTheOverride()
+ {
+ var pr = new PlanRegistration
+ {
+ NettoHours = 6.0,
+ NettoHoursOverride = 9.5,
+ NettoHoursOverrideActive = true,
+ PlanHours = 8.0,
+ PaiedOutFlex = 0
+ };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(
+ pr, new PlanRegistration { SumFlexEnd = 2.0 });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pr.Flex, Is.EqualTo(1.5).Within(1e-9), "9.5 - 8.0");
+ Assert.That(pr.SumFlexEnd, Is.EqualTo(3.5).Within(1e-9), "2.0 + 9.5 - 8.0 - 0");
+ });
+ }
+
+ [Test]
+ public void ClearSumFlexSeconds_ZeroesBothColumnsAndTouchesNothingElse()
+ {
+ var pr = new PlanRegistration
+ {
+ SumFlexStart = 1.5,
+ SumFlexEnd = 2.5,
+ SumFlexStartInSeconds = 111,
+ SumFlexEndInSeconds = 222,
+ FlexInSeconds = 333,
+ NettoHoursInSeconds = 444,
+ PaiedOutFlexInSeconds = 555
+ };
+
+ PlanRegistrationHelper.ClearSumFlexSeconds(pr);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pr.SumFlexStartInSeconds, Is.EqualTo(0));
+ Assert.That(pr.SumFlexEndInSeconds, Is.EqualTo(0));
+ Assert.That(pr.SumFlexStart, Is.EqualTo(1.5), "The decimals are the balance now.");
+ Assert.That(pr.SumFlexEnd, Is.EqualTo(2.5));
+ Assert.That(pr.FlexInSeconds, Is.EqualTo(333),
+ "Only the SumFlex pair is cleared — PaiedOutFlexInSeconds in "
+ + "particular is maintained on five-minute rows too.");
+ Assert.That(pr.NettoHoursInSeconds, Is.EqualTo(444));
+ Assert.That(pr.PaiedOutFlexInSeconds, Is.EqualTo(555));
+ });
+ }
+
+ ///
+ /// A MIXED-MODE chain end to end: a five-minute day carrying stale seconds,
+ /// then a one-minute day. The five-minute write clears the residue, and the
+ /// one-minute day therefore opens on the decimal balance — with or without
+ /// the mode hint, because after the clear the two agree.
+ ///
+ [Test]
+ public void MixedModeChain_FiveMinuteThenOneMinute_CarriesTheDecimalBalance()
+ {
+ var dayOne = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 27),
+ NettoHours = 9.0,
+ PlanHours = 8.0,
+ // Arbitrary non-zero residue from an earlier one-minute write; the
+ // exact values carry no meaning beyond "not zero, and not the
+ // decimal balance".
+ SumFlexStartInSeconds = 111111,
+ SumFlexEndInSeconds = 222222
+ };
+ var dayZero = new PlanRegistration { Date = new DateTime(2026, 8, 26), SumFlexEnd = 2.0 };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(dayOne, dayZero);
+
+ var dayTwo = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 28),
+ Start1StartedAt = new DateTime(2026, 8, 28, 8, 0, 0),
+ Stop1StoppedAt = new DateTime(2026, 8, 28, 16, 30, 30),
+ PlanHours = 8.0,
+ PlanHoursInSeconds = 28800
+ };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
+ dayTwo, dayOne, preIsOneMinute: false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(dayOne.SumFlexEnd, Is.EqualTo(3.0).Within(1e-9), "2.0 + 9.0 - 8.0");
+ Assert.That(dayOne.SumFlexEndInSeconds, Is.EqualTo(0));
+
+ Assert.That(dayTwo.SumFlexStartInSeconds, Is.EqualTo(10800),
+ "3.00 h carried across the boundary as 10800 s.");
+ Assert.That(dayTwo.NettoHoursInSeconds, Is.EqualTo(30630), "08:00–16:30:30");
+ Assert.That(dayTwo.SumFlexEndInSeconds, Is.EqualTo(10800 + 30630 - 28800),
+ "The 30 s survives the boundary.");
+ Assert.That(dayTwo.SumFlexEnd, Is.EqualTo(dayTwo.SumFlexEndInSeconds / 3600.0));
+ });
+ }
+
+ ///
+ /// Regression guard for the ONE-MINUTE side: a one-minute predecessor's
+ /// populated seconds column is still the source of truth, to the second.
+ ///
+ [Test]
+ public void OneMinutePredecessor_StillSeedsFromItsSecondsColumn()
+ {
+ var pre = new PlanRegistration
+ {
+ RegisteredUnderOneMinuteIntervals = true,
+ SumFlexEnd = 3.0, // a stale 2-decimal rendering…
+ SumFlexEndInSeconds = 10837 // …of a balance that is really 3 h 0 m 37 s
+ };
+ var pr = new PlanRegistration
+ {
+ Date = new DateTime(2026, 8, 28),
+ Start1StartedAt = new DateTime(2026, 8, 28, 8, 0, 0),
+ Stop1StoppedAt = new DateTime(2026, 8, 28, 16, 0, 0),
+ PlanHours = 8.0,
+ PlanHoursInSeconds = 28800
+ };
+
+ PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(pr, pre, preIsOneMinute: true);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(pr.SumFlexStartInSeconds, Is.EqualTo(10837),
+ "The 37 s is not lost to the decimal.");
+ Assert.That(pr.SumFlexEndInSeconds, Is.EqualTo(10837));
+ });
+ }
+
[Test]
public void GetDeclaredPayCodes_NullRuleSet_ReturnsEmpty()
{
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs
index fbe48d99..9d188175 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs
@@ -18,8 +18,10 @@ namespace TimePlanning.Pn.Test;
/// The chain forks per row: a one-minute row runs in the integer
/// *InSeconds columns and back-derives the doubles; a five-minute row
/// runs in the legacy 2-decimal doubles and its *InSeconds DTO fields are
-/// deliberately NOT written (the flag-off response stays byte-identical, and ops
-/// reads a zero there as the signal that the row never ran in one-minute mode).
+/// CLEARED (ops reads a zero there as the signal that the row does not carry a
+/// seconds balance — echoing back a stale value the database row still holds
+/// from an earlier one-minute write is what let the displayed balance disagree
+/// with the recomputed one).
/// Both running accumulators are nonetheless advanced on every row so the
/// balance carries across the boundary — that hand-off is what these tests pin.
///
@@ -51,7 +53,7 @@ public void SetUp()
private static TimePlanningWorkingHoursModel FiveMinuteRow(
int dayOfMonth, double flexHours, string paidOutFlex = "0",
- double sumFlexStart = 0, int sumFlexEndInSeconds = 0)
+ double sumFlexStart = 0, int sumFlexStartInSeconds = 0, int sumFlexEndInSeconds = 0)
=> new()
{
Date = new DateTime(2026, 6, dayOfMonth),
@@ -59,6 +61,7 @@ private static TimePlanningWorkingHoursModel FiveMinuteRow(
FlexHours = flexHours,
PaidOutFlex = paidOutFlex,
SumFlexStart = sumFlexStart,
+ SumFlexStartInSeconds = sumFlexStartInSeconds,
SumFlexEndInSeconds = sumFlexEndInSeconds
};
@@ -115,8 +118,8 @@ public void FiveMinuteThenOneMinute_CarriesTheBalanceToTheSecond()
Assert.That(rows[2].SumFlexEnd, Is.EqualTo(rows[2].SumFlexEndInSeconds / 3600.0));
Assert.That(rows[3].SumFlexEnd, Is.EqualTo(rows[3].SumFlexEndInSeconds / 3600.0));
- // Five-minute rows keep their *InSeconds DTO fields untouched — by
- // design, not by omission (see the fixture summary).
+ // Five-minute rows carry no seconds — by design, not by omission
+ // (see the fixture summary).
Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(0));
Assert.That(rows[1].SumFlexEndInSeconds, Is.EqualTo(0));
});
@@ -184,11 +187,13 @@ public void OneMinuteThenFiveMinute_HandsOffFullPrecision()
/// SumFlexEnd = Round(1.23 + 2.5 - 0.25, 2) = 3.48
/// row 1: SumFlexEnd = Round(3.48 - 1.1 - 0, 2) = 2.38
/// row 2: SumFlexEnd = Round(2.38 + 0.333333 - 0.5, 2) = 2.21
- /// The sentinel *InSeconds values prove the seconds bookkeeping added
- /// for the boundary hand-off writes nothing on a five-minute row.
+ /// The sentinel *InSeconds values prove a five-minute row is returned
+ /// carrying NO seconds balance: whatever the database row still holds from an
+ /// earlier one-minute write is cleared, so no consumer — and no later
+ /// recompute seeded off this response — can mistake it for a live balance.
///
[Test]
- public void UniformlyFiveMinute_MatchesTheLegacyFormulasAndWritesNoSeconds()
+ public void UniformlyFiveMinute_MatchesTheLegacyFormulasAndClearsSeconds()
{
const int sentinel = 424242;
var rows = new List
@@ -217,8 +222,10 @@ public void UniformlyFiveMinute_MatchesTheLegacyFormulasAndWritesNoSeconds()
foreach (var row in rows)
{
- Assert.That(row.SumFlexStartInSeconds, Is.EqualTo(sentinel));
- Assert.That(row.SumFlexEndInSeconds, Is.EqualTo(sentinel));
+ Assert.That(row.SumFlexStartInSeconds, Is.EqualTo(0),
+ "The sentinel must not survive: a five-minute row carries "
+ + "its balance in the decimals only.");
+ Assert.That(row.SumFlexEndInSeconds, Is.EqualTo(0));
}
});
}
@@ -267,6 +274,39 @@ public void OneMinuteAnchor_WithPopulatedSecondsColumn_IgnoresTheDecimal()
});
}
+ ///
+ /// The display-side twin of the persisted defect (tenant 994, site 21445):
+ /// a five-minute row whose SumFlexEndInSeconds still holds an older
+ /// one-minute write's value — -290456 s (-80.68 h) against a real decimal
+ /// balance of -3.97 h. The chain must return it cleared, and the following
+ /// one-minute row must open on the DECIMAL, not on the residue.
+ ///
+ [Test]
+ public void FiveMinuteRowWithStaleSeconds_IsClearedAndDoesNotPoisonTheBoundary()
+ {
+ var rows = new List
+ {
+ FiveMinuteRow(1, flexHours: -7.58, sumFlexStart: 3.61,
+ sumFlexStartInSeconds: -263168, sumFlexEndInSeconds: -290456),
+ OneMinuteRow(2, flexInSeconds: 0)
+ };
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexEnd, Is.EqualTo(-3.97).Within(1e-9),
+ "3.61 - 7.58 — the real closing balance.");
+ Assert.That(rows[0].SumFlexStartInSeconds, Is.EqualTo(0));
+ Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(0),
+ "-290456 s was residue, not a balance.");
+
+ Assert.That(rows[1].SumFlexStartInSeconds, Is.EqualTo(-14292),
+ "The next row opens on -3.97 h, NOT on -80.68 h.");
+ Assert.That(rows[1].SumFlexEnd, Is.EqualTo(-3.97).Within(0.001));
+ });
+ }
+
// ------------------------------------------------------------------ //
// 5. End to end: unmarked rows split by the site's effective date //
// ------------------------------------------------------------------ //
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 9df8bfb2..8a1206d5 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs
@@ -230,6 +230,33 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn
}
}
+ // ONE timeline per mapped site, built BEFORE the row loop and never
+ // per row. The update leg below may only clear a row's seconds
+ // columns once it knows the row ran in five-minute mode — see the
+ // INVERTED-SUMFLEX-SIGN note there for why clearing a one-minute
+ // row here would be actively harmful.
+ var oneMinuteTimelines = new Dictionary();
+ foreach (var mappedSite in columnSiteMap.Values)
+ {
+ // A site without a MicrotingUid cannot be resolved; skip it here
+ // rather than throwing, and let the lookup below fall through to
+ // "mode unknown" (which does NOT clear).
+ if (mappedSite.MicrotingUid == null
+ || oneMinuteTimelines.ContainsKey(mappedSite.MicrotingUid.Value))
+ {
+ continue;
+ }
+
+ var mappedSiteUid = mappedSite.MicrotingUid.Value;
+
+ var mappedAssignedSite = await dbContext.AssignedSites
+ .AsNoTracking()
+ .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
+ .FirstOrDefaultAsync(x => x.SiteId == mappedSiteUid);
+ oneMinuteTimelines[mappedSiteUid] =
+ await OneMinuteModeTimeline.BuildAsync(dbContext, mappedAssignedSite);
+ }
+
// Skip the header row (first row)
for (var i = 1; i < values.Count; i++)
{
@@ -394,6 +421,45 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn
planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
}
+ // KNOWN BUG, UNFIXED AND OUT OF SCOPE HERE — search tag:
+ // INVERTED-SUMFLEX-SIGN.
+ // This leg computes
+ // SumFlexEnd = SumFlexStart + PlanHours - NettoHours - PaiedOutFlex
+ // where the canonical chain (PlanRegistrationHelper
+ // .ApplyNettoFlexChainDecimal / ...SecondPrecision) is
+ // SumFlexEnd = SumFlexStart + NettoHours - PlanHours - PaiedOutFlex
+ // — the NettoHours/PlanHours operands are the wrong way
+ // round, so the balance moves the wrong direction on any
+ // day where the two differ. Note its own Flex line just
+ // above uses the CORRECT order, so Flex and SumFlexEnd
+ // disagree with each other on the same row. The identical
+ // inversion exists in TimePlanningWorkingHoursService
+ // .Import's update leg. Deliberately NOT fixed in this
+ // change (which only alters which *InSeconds columns get
+ // written); fixing it restates historical balances and
+ // needs its own change, review and rollback path.
+ //
+ // What IS new here: this leg rewrites an EXISTING row's
+ // decimal balance, so a five-minute row's seconds columns
+ // must not keep an earlier one-minute write's value — the
+ // next row would seed its whole chain from it.
+ //
+ // The clear is MODE-GATED, and that gate is load-bearing
+ // BECAUSE of the inversion above: zeroing a genuine
+ // one-minute row's seconds would make every reader fall
+ // back to the decimal this leg just wrote with the wrong
+ // sign. A one-minute row keeps its seconds untouched here.
+ // Unresolvable mode => leave the row exactly as it was.
+ // Not clearing preserves the pre-existing behaviour;
+ // clearing a one-minute row would not.
+ if (site.MicrotingUid != null
+ && oneMinuteTimelines.TryGetValue(
+ site.MicrotingUid.Value, out var siteTimeline)
+ && !siteTimeline.WasOneMinuteForRow(planRegistration))
+ {
+ PlanRegistrationHelper.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
index e04850cd..f52a3c05 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs
@@ -223,10 +223,8 @@ public static void StampEffectiveDateOnEnable(
/// 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 keep using
- /// row.RegisteredUnderOneMinuteIntervals ?? timeline.WasOneMinuteAt(row.Date),
- /// which carries the same precedence because
- /// consults the effective date first.
+ /// 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
@@ -256,6 +254,37 @@ public static async Task ResolveRowModeAsync(
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
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 377c5b48..d929b29a 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
@@ -483,11 +483,102 @@ public static int SecondsOrDecimalFallback(int seconds, double hours)
/// post-switch row seeds from the last PRE-switch row, which by definition
/// only ever had the decimal columns written.
///
- public static int SumFlexEndSecondsWithFallback(PlanRegistration? preTimePlanning)
- => preTimePlanning == null
- ? 0
- : SecondsOrDecimalFallback(
- preTimePlanning.SumFlexEndInSeconds, preTimePlanning.SumFlexEnd);
+ ///
+ /// 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.
@@ -523,18 +614,6 @@ public static int SumFlexEndSecondsWithFallback(PlanRegistration? preTimePlannin
/// True when there is a preceding planning row (use the running balance);
/// false when this is the first row (reset SumFlexStart to 0).
///
- ///
- /// 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.
- ///
- public static void ApplyNettoFlexChainSecondPrecision(
- PlanRegistration pr, PlanRegistration? preTimePlanning)
- => ApplyNettoFlexChainSecondPrecision(
- pr, SumFlexEndSecondsWithFallback(preTimePlanning), preTimePlanning != null);
-
public static void ApplyNettoFlexChainSecondPrecision(PlanRegistration pr,
int sumFlexStartInSeconds, bool hasPreTimePlanning)
{
@@ -718,48 +797,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration, preTimePlanning);
- }
- else if (preTimePlanning != null)
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHoursOverride -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- }
- else
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHours -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ planRegistration, preTimePlanning,
+ oneMinuteTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- }
- else
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHours - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning);
}
await planRegistration.Update(dbContext).ConfigureAwait(false);
@@ -1046,45 +1089,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration, preTimePlanning);
- }
- else if (preTimePlanning != null)
- {
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHoursOverride -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- } else
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHours -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ planRegistration, preTimePlanning,
+ oneMinuteTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- } else
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHours - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning);
}
await planRegistration.Update(dbContext).ConfigureAwait(false);
}
@@ -1399,7 +1409,8 @@ public static async Task UpdatePlanRegistration(
PlanRegistration planRegistration,
TimePlanningPnDbContext dbContext,
AssignedSite dbAssignedSite,
- DateTime dayOfPayment
+ DateTime dayOfPayment,
+ OneMinuteModeTimeline oneMinuteTimeline
)
{
if (dbAssignedSite.Resigned)
@@ -1407,9 +1418,9 @@ DateTime dayOfPayment
return planRegistration;
}
// Mode AT REGISTRATION for this row, never the site's current flag —
- // see OneMinuteModeTimeline.
- var rowIsOneMinute = await OneMinuteModeTimeline.ResolveRowModeAsync(
- dbContext, dbAssignedSite, planRegistration);
+ // see OneMinuteModeTimeline. The timeline is built ONCE by the calling
+ // loop and passed in, so this never costs a query per row.
+ var rowIsOneMinute = oneMinuteTimeline.WasOneMinuteForRow(planRegistration);
var tainted = false;
// foreach (var plan in planningsInPeriod)
// {
@@ -1471,47 +1482,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration, preTimePlanning);
- }
- else if (preTimePlanning != null)
- {
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHoursOverride -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- }
- else
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHours -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ planRegistration, preTimePlanning,
+ oneMinuteTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- }
- else
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHours - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning);
}
await planRegistration.Update(dbContext).ConfigureAwait(false);
@@ -1786,45 +1762,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration, preTimePlanning);
- }
- else if (preTimePlanning != null)
- {
- if (planRegistration.NettoHoursOverrideActive)
- {planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHoursOverride -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- } else
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.NettoHours -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ planRegistration, preTimePlanning,
+ oneMinuteTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
- if (planRegistration.NettoHoursOverrideActive)
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHoursOverride - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHoursOverride - planRegistration.PlanHours;
- }
- else
- {
- planRegistration.SumFlexEnd =
- planRegistration.NettoHours - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
- }
+ ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning);
}
// Console.WriteLine($"The plannedHours are now: {planRegistration.PlanHours}");
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 a67e2c85..bbccd6cc 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
@@ -275,8 +275,12 @@ private async Task UpdatePlanning(PlanRegistration planRegistration,
// the payout delta twice.
var rowIsOneMinute = await OneMinuteModeTimeline.ResolveRowModeAsync(
dbContext, assignedSite, planRegistration);
- var oldSumFlexEndSeconds =
- PlanRegistrationHelper.SumFlexEndSecondsWithFallback(planRegistration);
+ // Only the one-minute branch consumes this. On a five-minute row the
+ // 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)
+ : 0;
planRegistration.SumFlexEnd += planRegistration.PaiedOutFlex - model.PaidOutFlex;
@@ -285,6 +289,11 @@ private async Task UpdatePlanning(PlanRegistration planRegistration,
planRegistration.SumFlexEndInSeconds =
oldSumFlexEndSeconds + oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds;
}
+ else
+ {
+ // Five-minute row: the adjusted decimal above is the whole balance.
+ PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration);
+ }
planRegistration.PaiedOutFlex = model.PaidOutFlex;
planRegistration.PaiedOutFlexInSeconds = newPaiedOutFlexSeconds;
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 9ae36c00..5edbecf8 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs
@@ -383,23 +383,14 @@ await innerDbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- if (preTimePlanning != null)
- {
- newPlanRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- newPlanRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd - newPlanRegistration.NettoHours -
- newPlanRegistration.PlanHours -
- newPlanRegistration.PaiedOutFlex;
- newPlanRegistration.Flex = newPlanRegistration.NettoHours - newPlanRegistration.PlanHours;
- }
- else
- {
- newPlanRegistration.SumFlexEnd =
- newPlanRegistration.NettoHours - newPlanRegistration.PlanHours -
- newPlanRegistration.PaiedOutFlex;
- newPlanRegistration.SumFlexStart = 0;
- newPlanRegistration.Flex = newPlanRegistration.NettoHours - newPlanRegistration.PlanHours;
- }
+ // A freshly constructed row has NettoHours, PlanHours
+ // and PaiedOutFlex all 0, so this simply carries the
+ // predecessor's closing balance forward. Routed through
+ // 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(
+ newPlanRegistration, preTimePlanning);
}
await newPlanRegistration.Create(innerDbContext);
@@ -606,21 +597,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- if (preTimePlanning != null)
- {
- newPlanRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- newPlanRegistration.SumFlexEnd = preTimePlanning.SumFlexEnd + newPlanRegistration.NettoHours -
- newPlanRegistration.PlanHours -
- newPlanRegistration.PaiedOutFlex;
- newPlanRegistration.Flex = newPlanRegistration.NettoHours - newPlanRegistration.PlanHours;
- }
- else
- {
- newPlanRegistration.SumFlexEnd = newPlanRegistration.NettoHours - newPlanRegistration.PlanHours -
- newPlanRegistration.PaiedOutFlex;
- newPlanRegistration.SumFlexStart = 0;
- newPlanRegistration.Flex = newPlanRegistration.NettoHours - newPlanRegistration.PlanHours;
- }
+ // See the note in Index: every operand is 0 on a freshly
+ // 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(
+ newPlanRegistration, preTimePlanning);
}
await newPlanRegistration.Create(dbContext);
@@ -1069,34 +1051,23 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
+ // ONE query for this row's predecessor AND the whole forward
+ // cascade below — never per row.
+ var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planning, preTimePlanning);
+ planning, preTimePlanning,
+ cascadeTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
planning.NettoHours = hours;
- var preSumFlexEnd = preTimePlanning?.SumFlexEnd ?? 0;
- planning.SumFlexStart = preSumFlexEnd;
- if (planning.NettoHoursOverrideActive)
- {
- planning.SumFlexEnd = preSumFlexEnd + planning.NettoHoursOverride -
- planning.PlanHours -
- planning.PaiedOutFlex;
- planning.Flex = planning.NettoHoursOverride - planning.PlanHours;
- }
- else
- {
- planning.SumFlexEnd = preSumFlexEnd + planning.NettoHours -
- planning.PlanHours -
- planning.PaiedOutFlex;
- planning.Flex = planning.NettoHours - planning.PlanHours;
- }
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(planning, preTimePlanning);
}
// Ensure timestamps are populated from IDs for accurate time tracking calculation
@@ -1128,9 +1099,6 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderBy(x => x.Date)
.ToList();
- // ONE query for the whole cascade below — never per row.
- var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
-
foreach (var planningAfterThisPlanning in planningsAfterThisPlanning)
{
var preTimePlanningAfterThisPlanning =
@@ -1143,50 +1111,16 @@ await dbContext.PlanRegistrations.AsNoTracking()
// These are OTHER, already-registered rows, so fork on the mode
// AT REGISTRATION — see OneMinuteModeTimeline.
- if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals
- ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date))
+ if (cascadeTimeline.WasOneMinuteForRow(planningAfterThisPlanning))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
- }
- else if (preTimePlanningAfterThisPlanning != null)
- {
- planningAfterThisPlanning.SumFlexStart = preTimePlanningAfterThisPlanning.SumFlexEnd;
- if (planningAfterThisPlanning.NettoHoursOverrideActive)
- {
- planningAfterThisPlanning.SumFlexEnd = preTimePlanningAfterThisPlanning.SumFlexEnd +
- planningAfterThisPlanning.NettoHoursOverride -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHoursOverride - planningAfterThisPlanning.PlanHours;
- }
- else
- {
- planningAfterThisPlanning.SumFlexEnd = preTimePlanningAfterThisPlanning.SumFlexEnd +
- planningAfterThisPlanning.NettoHours -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHours - planningAfterThisPlanning.PlanHours;
- }
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning,
+ cascadeTimeline.WasOneMinuteFor(preTimePlanningAfterThisPlanning));
}
else
{
- // No previous planning found, start from 0
- if (planningAfterThisPlanning.NettoHoursOverrideActive)
- {
- planningAfterThisPlanning.SumFlexEnd = planningAfterThisPlanning.NettoHoursOverride -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHoursOverride - planningAfterThisPlanning.PlanHours;
- }
- else
- {
- planningAfterThisPlanning.SumFlexEnd = planningAfterThisPlanning.NettoHours -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHours - planningAfterThisPlanning.PlanHours;
- }
- planningAfterThisPlanning.SumFlexStart = 0;
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
}
await planningAfterThisPlanning.Update(dbContext).ConfigureAwait(false);
@@ -1463,17 +1397,31 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
+ // ONE query for this row's predecessor AND the whole forward
+ // cascade below — never per row.
+ var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planning, preTimePlanning);
+ planning, preTimePlanning,
+ cascadeTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
+ // INTENTIONAL DIVERGENCE — do not "unify" without a separate change.
+ // This leg keeps its own decimal formula, which does NOT consult
+ // NettoHoursOverrideActive: it always chains on the computed
+ // NettoHours, unlike the one-minute branch above and unlike every
+ // other decimal chain site (which route through
+ // PlanRegistrationHelper.ApplyNettoFlexChainDecimal and DO honour
+ // the override). Making them agree is a candidate follow-up, but it
+ // changes payroll arithmetic and must be reviewed and rolled back
+ // independently of the seconds-column fix; it is deliberately out of
+ // scope here. Only the ClearSumFlexSeconds call below is new.
planning.NettoHours = hours;
var preSumFlexEnd = preTimePlanning?.SumFlexEnd ?? 0;
planning.SumFlexStart = preSumFlexEnd;
@@ -1481,6 +1429,9 @@ await dbContext.PlanRegistrations.AsNoTracking()
planning.PlanHours -
planning.PaiedOutFlex;
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);
}
// Ensure timestamps are populated from IDs for accurate time tracking calculation
@@ -1501,9 +1452,6 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderBy(x => x.Date)
.ToList();
- // ONE query for the whole cascade below — never per row.
- var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
-
foreach (var planningAfterThisPlanning in planningsAfterThisPlanning)
{
var preTimePlanningAfterThisPlanning =
@@ -1516,50 +1464,16 @@ await dbContext.PlanRegistrations.AsNoTracking()
// These are OTHER, already-registered rows, so fork on the mode
// AT REGISTRATION — see OneMinuteModeTimeline.
- if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals
- ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date))
+ if (cascadeTimeline.WasOneMinuteForRow(planningAfterThisPlanning))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
- }
- else if (preTimePlanningAfterThisPlanning != null)
- {
- planningAfterThisPlanning.SumFlexStart = preTimePlanningAfterThisPlanning.SumFlexEnd;
- if (planningAfterThisPlanning.NettoHoursOverrideActive)
- {
- planningAfterThisPlanning.SumFlexEnd = preTimePlanningAfterThisPlanning.SumFlexEnd +
- planningAfterThisPlanning.NettoHoursOverride -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHoursOverride - planningAfterThisPlanning.PlanHours;
- }
- else
- {
- planningAfterThisPlanning.SumFlexEnd = preTimePlanningAfterThisPlanning.SumFlexEnd +
- planningAfterThisPlanning.NettoHours -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHours - planningAfterThisPlanning.PlanHours;
- }
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning,
+ cascadeTimeline.WasOneMinuteFor(preTimePlanningAfterThisPlanning));
}
else
{
- // No previous planning found, start from 0
- if (planningAfterThisPlanning.NettoHoursOverrideActive)
- {
- planningAfterThisPlanning.SumFlexEnd = planningAfterThisPlanning.NettoHoursOverride -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHoursOverride - planningAfterThisPlanning.PlanHours;
- }
- else
- {
- planningAfterThisPlanning.SumFlexEnd = planningAfterThisPlanning.NettoHours -
- planningAfterThisPlanning.PlanHours -
- planningAfterThisPlanning.PaiedOutFlex;
- planningAfterThisPlanning.Flex = planningAfterThisPlanning.NettoHours - planningAfterThisPlanning.PlanHours;
- }
- planningAfterThisPlanning.SumFlexStart = 0;
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
}
await planningAfterThisPlanning.Update(dbContext).ConfigureAwait(false);
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 2987148c..b6e23e2b 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
@@ -288,8 +288,7 @@ public async Task>> Inde
if (lastPlanning != null)
{
// Mode AT REGISTRATION for the carried-over previous-day row.
- var lastPlanningIsOneMinute = lastPlanning.RegisteredUnderOneMinuteIntervals
- ?? oneMinuteTimeline.WasOneMinuteAt(lastPlanning.Date);
+ var lastPlanningIsOneMinute = oneMinuteTimeline.WasOneMinuteForRow(lastPlanning);
// lastPlanning.Date = new DateTime(lastPlanning.Date.Year, lastPlanning.Date.Month, lastPlanning.Date.Day, 0, 0, 0);
@@ -350,8 +349,14 @@ public async Task>> Inde
// Phase 2: second-precision siblings for the SumFlex chain.
NettoHoursInSeconds = lastPlanning?.NettoHoursInSeconds ?? 0,
FlexInSeconds = lastPlanning?.FlexInSeconds ?? 0,
- SumFlexStartInSeconds = lastPlanning?.SumFlexStartInSeconds ?? 0,
- SumFlexEndInSeconds = PlanRegistrationHelper.SumFlexEndSecondsWithFallback(lastPlanning),
+ // Mode-aware: a five-minute row carries its balance in
+ // the decimals only, so its seconds columns are residue
+ // from an earlier one-minute write, never a balance.
+ SumFlexStartInSeconds = lastPlanningIsOneMinute
+ ? lastPlanning?.SumFlexStartInSeconds ?? 0
+ : 0,
+ SumFlexEndInSeconds = PlanRegistrationHelper.SumFlexEndSecondsWithFallback(
+ lastPlanning, lastPlanningIsOneMinute),
PaiedOutFlexInSeconds = lastPlanning?.PaiedOutFlexInSeconds ?? 0,
Message = lastPlanning?.MessageId,
CommentWorker = lastPlanning?.WorkerComment?.Replace("\r", "
"),
@@ -468,7 +473,8 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr
var planRegistration = planRegistrations.FirstOrDefault(x => x.Date == planning.Date);
if (planRegistration != null)
{
- await UpdatePlanning(first, planRegistration, planning, model.SiteId);
+ await UpdatePlanning(
+ first, planRegistration, planning, model.SiteId, cascadeTimeline);
}
else
{
@@ -513,34 +519,19 @@ await dbContext.PlanRegistrations.AsNoTracking()
// grid (which recomputes from seconds).
// Mode AT REGISTRATION for THIS later row — see
// OneMinuteModeTimeline.
- if (planRegistration.RegisteredUnderOneMinuteIntervals
- ?? cascadeTimeline.WasOneMinuteAt(planRegistration.Date))
+ if (cascadeTimeline.WasOneMinuteForRow(planRegistration))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration, preTimePlanning);
+ planRegistration, preTimePlanning,
+ cascadeTimeline.WasOneMinuteFor(preTimePlanning));
}
else
{
- // Flag-off path keeps the legacy double formula but now honours
- // NettoHoursOverrideActive, matching UpdatePlanRegistration.
- var effectiveNettoHours = planRegistration.NettoHoursOverrideActive
- ? planRegistration.NettoHoursOverride
- : planRegistration.NettoHours;
- if (preTimePlanning != null)
- {
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- planRegistration.SumFlexEnd = preTimePlanning.SumFlexEnd + effectiveNettoHours -
- planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- }
- else
- {
- planRegistration.SumFlexStart = 0;
- planRegistration.SumFlexEnd = effectiveNettoHours - planRegistration.PlanHours -
- planRegistration.PaiedOutFlex;
- }
-
- planRegistration.Flex = effectiveNettoHours - planRegistration.PlanHours;
+ // Flag-off path keeps the legacy double formula (it honours
+ // NettoHoursOverrideActive, matching UpdatePlanRegistration)
+ // and clears the seconds columns with the same call.
+ PlanRegistrationHelper.ApplyNettoFlexChainDecimal(
+ planRegistration, preTimePlanning);
}
await planRegistration.Update(dbContext);
@@ -635,7 +626,8 @@ await dbContext.PlanRegistrations.AsNoTracking()
private async Task UpdatePlanning(bool first, PlanRegistration planRegistration,
TimePlanningWorkingHoursModel model,
- int microtingUid)
+ int microtingUid,
+ OneMinuteModeTimeline timeline)
{
var dateTime = DateTime.Now;
var midnight = new DateTime(dateTime.Year, dateTime.Month, dateTime.Day, 0, 0, 0);
@@ -694,7 +686,8 @@ private async Task UpdatePlanning(bool first, PlanRegistration planRegistration,
}
planRegistration = await PlanRegistrationHelper
- .UpdatePlanRegistration(planRegistration, dbContext, assignedSite, DateTime.Now.AddMonths(-1));
+ .UpdatePlanRegistration(
+ planRegistration, dbContext, assignedSite, DateTime.Now.AddMonths(-1), timeline);
await planRegistration.Update(dbContext);
}
@@ -999,6 +992,15 @@ private static double PaidOutFlexHours(TimePlanningWorkingHoursModel model)
? 0
: double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture);
+ ///
+ /// The DTO-side .
+ ///
+ private static void ClearRowSumFlexSeconds(TimePlanningWorkingHoursModel row)
+ {
+ row.SumFlexStartInSeconds = 0;
+ row.SumFlexEndInSeconds = 0;
+ }
+
///
/// Applies the running flex-balance chain over an ordered-by-date list of
/// working-hours rows. Single source of truth for the flex balance rendered
@@ -1013,9 +1015,10 @@ private static double PaidOutFlexHours(TimePlanningWorkingHoursModel model)
/// For a one-minute row the chain runs in the integer *InSeconds columns
/// (the source of truth) and back-derives the legacy double SumFlex*
/// fields via /3600.0; a 5-minute row runs in the legacy rounded doubles
- /// and its *InSeconds DTO fields are deliberately left untouched (the
- /// flag-off response stays byte-identical, and ops reads a zero there as the
- /// signal that the row never ran in one-minute mode). BOTH running accumulators
+ /// and its *InSeconds DTO fields are CLEARED (ops reads a zero there as
+ /// the signal that the row does not carry a seconds balance — echoing back a
+ /// stale value the row happens to still hold in the database is what let the
+ /// displayed balance disagree with the recomputed one). BOTH running accumulators
/// are nonetheless kept in lockstep after every row, so the balance carries
/// correctly across a mode boundary in either direction.
///
@@ -1051,6 +1054,7 @@ internal void ApplyRunningFlexChain(
row.SumFlexStart = Math.Round(row.SumFlexStart, 2);
row.SumFlexEnd = Math.Round(
row.SumFlexStart + row.FlexHours - PaidOutFlexHours(row), 2);
+ ClearRowSumFlexSeconds(row);
}
}
else
@@ -1087,6 +1091,8 @@ internal void ApplyRunningFlexChain(
logger.LogError(e.Message);
logger.LogTrace(e.StackTrace);
}
+
+ ClearRowSumFlexSeconds(row);
}
}
@@ -1287,6 +1293,49 @@ private static double ComputeFlagOffNettoMinutes(PlanRegistration pr)
return nettoMinutes;
}
+ ///
+ /// The five-minute (flag-off) decimal flex chain used by the FOUR
+ /// mobile/kiosk punch-clock save legs, byte-for-byte the formula they have
+ /// always used — extracted only so the seconds-column clear cannot be
+ /// forgotten by one of them.
+ ///
+ /// INTENTIONAL DIVERGENCE — do not "unify" without a separate change. This
+ /// chain differs from the canonical
+ /// in two
+ /// ways, both deliberate here:
+ /// 1. it does NOT consult NettoHoursOverrideActive — it always
+ /// chains on the computed netto hours, unlike the one-minute branch of
+ /// the same fork and unlike every other decimal chain site;
+ /// 2. it associates the arithmetic as
+ /// SumFlexStart + Flex - PaiedOutFlex rather than
+ /// SumFlexStart + Netto - PlanHours - PaiedOutFlex, which is the
+ /// same value in exact arithmetic but can differ in the last bit of a
+ /// double.
+ /// Unifying them is a candidate follow-up, out of scope here for the reason
+ /// spelled out at the other gated site
+ /// (TimePlanningPlanningService.UpdateByCurrentUserNam). The ONLY new
+ /// behaviour here is the ClearSumFlexSeconds call.
+ ///
+ private static void ApplyPunchClockFlexChainDecimal(
+ PlanRegistration planRegistration, PlanRegistration? preTimePlanning, double hours)
+ {
+ planRegistration.NettoHours = hours;
+ planRegistration.Flex = hours - planRegistration.PlanHours;
+ if (preTimePlanning != null)
+ {
+ planRegistration.SumFlexEnd =
+ preTimePlanning.SumFlexEnd + planRegistration.Flex - planRegistration.PaiedOutFlex;
+ planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
+ }
+ else
+ {
+ planRegistration.SumFlexEnd = planRegistration.Flex - planRegistration.PaiedOutFlex;
+ planRegistration.SumFlexStart = 0;
+ }
+
+ PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration);
+ }
+
public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model)
{
Console.WriteLine($"[DEBUG-GRPC-UPDATE] === UpdateWorkingHour (PERSONAL mode, 1-param) entered ===");
@@ -1641,27 +1690,20 @@ await dbContext.PlanRegistrations.AsNoTracking()
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
+ // 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(
- planRegistration, preTimePlanning);
+ planRegistration, preTimePlanning,
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(
+ dbContext, assignedSite, preTimePlanning));
}
else
{
- planRegistration.NettoHours = hours;
- planRegistration.Flex = hours - planRegistration.PlanHours;
- if (preTimePlanning != null)
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- }
- else
- {
- planRegistration.SumFlexEnd = planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- }
+ ApplyPunchClockFlexChainDecimal(planRegistration, preTimePlanning, hours);
}
Console.WriteLine($"[DEBUG-GRPC-UPDATE] PERSONAL CREATE: Before planRegistration.Create(dbContext) -- SdkSitId={planRegistration.SdkSitId}, Date={planRegistration.Date:yyyy-MM-dd}, Start1Id={planRegistration.Start1Id}, Stop1Id={planRegistration.Stop1Id}, Pause1Id={planRegistration.Pause1Id}, Start1StartedAt={planRegistration.Start1StartedAt}, Stop1StoppedAt={planRegistration.Stop1StoppedAt}, NettoHours={planRegistration.NettoHours}");
@@ -1942,27 +1984,20 @@ await dbContext.PlanRegistrations.AsNoTracking()
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
+ // 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(
- planRegistration, preTimePlanning);
+ planRegistration, preTimePlanning,
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(
+ dbContext, assignedSite, preTimePlanning));
}
else
{
- planRegistration.NettoHours = hours;
- planRegistration.Flex = hours - planRegistration.PlanHours;
- if (preTimePlanning != null)
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- }
- else
- {
- planRegistration.SumFlexEnd = planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- }
+ ApplyPunchClockFlexChainDecimal(planRegistration, preTimePlanning, hours);
}
Console.WriteLine($"[DEBUG-GRPC-UPDATE] PERSONAL UPDATE: Before planRegistration.Update(dbContext) -- Id={planRegistration.Id}, SdkSitId={planRegistration.SdkSitId}, Date={planRegistration.Date:yyyy-MM-dd}, Start1Id={planRegistration.Start1Id}, Stop1Id={planRegistration.Stop1Id}, Pause1Id={planRegistration.Pause1Id}, Start1StartedAt={planRegistration.Start1StartedAt}, Stop1StoppedAt={planRegistration.Stop1StoppedAt}, NettoHours={planRegistration.NettoHours}");
@@ -2302,27 +2337,20 @@ await dbContext.PlanRegistrations.AsNoTracking()
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
+ // 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(
- planRegistration, preTimePlanning);
+ planRegistration, preTimePlanning,
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(
+ dbContext, assignedSite, preTimePlanning));
}
else
{
- planRegistration.NettoHours = hours;
- planRegistration.Flex = hours - planRegistration.PlanHours;
- if (preTimePlanning != null)
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- }
- else
- {
- planRegistration.SumFlexEnd = planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- }
+ ApplyPunchClockFlexChainDecimal(planRegistration, preTimePlanning, hours);
}
Console.WriteLine($"[DEBUG-GRPC-UPDATE] KIOSK CREATE: Before planRegistration.Create(dbContext) -- SdkSitId={planRegistration.SdkSitId}, Date={planRegistration.Date:yyyy-MM-dd}, Start1Id={planRegistration.Start1Id}, Stop1Id={planRegistration.Stop1Id}, Pause1Id={planRegistration.Pause1Id}, Start1StartedAt={planRegistration.Start1StartedAt}, Stop1StoppedAt={planRegistration.Stop1StoppedAt}, NettoHours={planRegistration.NettoHours}");
@@ -2592,27 +2620,20 @@ await dbContext.PlanRegistrations.AsNoTracking()
// Phase 2: when UseOneMinuteIntervals is on, recompute NettoHours
// from DateTime deltas (precise to the second) and write
// *InSeconds columns as the source of truth; back-derive the
- // legacy double hour fields. Flag-off path stays byte-identical.
+ // legacy double hour fields.
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
+ // 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(
- planRegistration, preTimePlanning);
+ planRegistration, preTimePlanning,
+ await OneMinuteModeTimeline.ResolveRowModeOrNullAsync(
+ dbContext, assignedSite, preTimePlanning));
}
else
{
- planRegistration.NettoHours = hours;
- planRegistration.Flex = hours - planRegistration.PlanHours;
- if (preTimePlanning != null)
- {
- planRegistration.SumFlexEnd =
- preTimePlanning.SumFlexEnd + planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = preTimePlanning.SumFlexEnd;
- }
- else
- {
- planRegistration.SumFlexEnd = planRegistration.Flex - planRegistration.PaiedOutFlex;
- planRegistration.SumFlexStart = 0;
- }
+ ApplyPunchClockFlexChainDecimal(planRegistration, preTimePlanning, hours);
}
Console.WriteLine($"[DEBUG-GRPC-UPDATE] KIOSK UPDATE: Before planRegistration.Update(dbContext) -- Id={planRegistration.Id}, SdkSitId={planRegistration.SdkSitId}, Date={planRegistration.Date:yyyy-MM-dd}, Start1Id={planRegistration.Start1Id}, Stop1Id={planRegistration.Stop1Id}, Pause1Id={planRegistration.Pause1Id}, Start1StartedAt={planRegistration.Start1StartedAt}, Stop1StoppedAt={planRegistration.Stop1StoppedAt}, NettoHours={planRegistration.NettoHours}");
@@ -3914,6 +3935,17 @@ public async Task Import(IFormFile file)
continue;
}
+ // ONE timeline per sheet (= per site), built BEFORE the row
+ // loop and never per row. The update leg below may only clear
+ // a row's seconds columns once it knows the row ran in
+ // five-minute mode — see the INVERTED-SUMFLEX-SIGN note there.
+ var importAssignedSite = await dbContext.AssignedSites
+ .AsNoTracking()
+ .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
+ .FirstOrDefaultAsync(x => x.SiteId == site.MicrotingUid);
+ var importTimeline =
+ await OneMinuteModeTimeline.BuildAsync(dbContext, importAssignedSite);
+
var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value);
var sheetData = worksheetPart.Worksheet.Elements().First();
@@ -4038,6 +4070,18 @@ public async Task Import(IFormFile file)
planRegistration.Flex = planRegistration.NettoHours - planRegistration.PlanHours;
}
+ // KNOWN BUG, UNFIXED AND OUT OF SCOPE HERE — search
+ // tag: INVERTED-SUMFLEX-SIGN. This leg carries the
+ // same inverted SumFlexEnd sign as
+ // GoogleSheetHelper.PushToGoogleSheet's update leg;
+ // that site holds the full explanation and the reason
+ // the clear below MUST stay mode-gated. A one-minute
+ // row keeps its seconds untouched here.
+ if (!importTimeline.WasOneMinuteForRow(planRegistration))
+ {
+ PlanRegistrationHelper.ClearSumFlexSeconds(planRegistration);
+ }
+
await planRegistration.Update(dbContext);
}
}