From 5dc808650402185cffbc015df34814323958318d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 2 Sep 2026 10:55:15 +0200 Subject: [PATCH] fix(flex): five-minute rows must not leave stale seconds behind The flex balance is stored twice: decimal hours and seconds. One-minute rows write all four columns; five-minute rows write only the decimals and leave SumFlexStartInSeconds/SumFlexEndInSeconds as they were. That is deliberate, and harmless while those columns are 0. It is not harmless when they hold a stale non-zero value. The chain seed is `seconds != 0 ? seconds : Round(hours * 3600)`, which treats non-zero as trustworthy, so a five-minute row carrying a leftover poisons its successor's seed. Seen in production on tenant 994 site 21445: predecessor dated 2026-08-27 with SumFlexEnd -3.97 and SumFlexEndInSeconds -290456; the next row opened at -80.68 h instead of -3.97 h. A 76.71-hour error on a live site. Note that predecessor's DATE is after the site's effective date -- it resolves five-minute via its write-time marker. Any fix keyed on dates alone misses it. Two halves, both needed: - ApplyNettoFlexChainDecimal writes the decimals and clears the seconds in one call, so a call site cannot do one without the other -- the same trick the 2-arg ApplyNettoFlexChainSecondPrecision overload uses on the one-minute side. ClearSumFlexSeconds covers legs that must keep their own formula. - The seed ignores a predecessor's seconds column entirely when that predecessor resolves five-minute, using the full precedence (marker, then effective date, then the audit timeline). This matters beyond our own writes: the background service writes decimals and never touches the seconds columns at all, so rows from that path stay exposed no matter what this plugin does. The clear is mode-gated everywhere, including the two legacy bulk paths (GoogleSheet pull, Excel import). Those legs run unscoped by site name -- the sheet pull on every global settings save -- and compute their decimal with a known inverted sign, so clearing a one-minute row's seconds there would have handed every reader the wrong value. They now build one timeline per site before their row loop and clear only five-minute rows; an unresolvable site leaves the row alone. Deliberately unchanged: NettoHoursOverride handling at five sites that never consulted it, and the inverted sign in the GoogleSheet and Import legs. Both are marked in the code -- grep INTENTIONAL DIVERGENCE and INVERTED-SUMFLEX-SIGN. Neither belongs in a fix about which columns get written. No decimal SumFlexStart/SumFlexEnd/Flex value changes, at any call site, for any input -- verified per site including floating-point association -- except where a five-minute predecessor's stale seconds were previously used as the seed. That change is the fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .../OneMinuteIntervalsEffectiveDateTests.cs | 188 +++++++++++- .../PlanRegistrationHelperTests.cs | 203 +++++++++++++ .../RunningFlexChainModeBoundaryTests.cs | 60 +++- .../Helpers/GoogleSheetHelper.cs | 66 ++++ .../Helpers/OneMinuteModeTimeline.cs | 37 ++- .../Helpers/PlanRegistrationHelper.cs | 283 +++++++----------- .../TimePlanningFlexService.cs | 13 +- .../TimePlanningPlanningService.cs | 186 ++++-------- .../TimePlanningWorkingHoursService.cs | 230 ++++++++------ 9 files changed, 849 insertions(+), 417 deletions(-) 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); } }