From a5fea9d7e505b78c8180bc2170ace865d49a392e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Mon, 31 Aug 2026 16:14:24 +0200 Subject: [PATCH] fix(flex): respect the UseOneMinuteIntervals effective date UseOneMinuteIntervals is a per-site boolean with no effective-from date, so every flex recomputation re-derived a worker's entire history under the site's CURRENT mode. Switching it on recalculated already-closed periods at one-minute precision and changed their balances. On one tenant a mass flag flip rewrote 3,017 rows: 2,267 balances dropped, averaging -9.6 h, worst -93.6 h. Resolve the mode PER ROW instead of per site, preferring the write-time marker, then the new AssignedSite.UseOneMinuteIntervalsFrom (base 10.0.61), then the existing AssignedSiteVersions timeline. The calc forks now use that resolved value; the timeline already computed it but was deliberately display-only. Write-time stamps and DTO passthroughs keep the raw flag: a new registration must record the CURRENT mode. Also seed the chain with a decimal fallback. SumFlexEndInSeconds is 0 on ~97% of rows -- migration 20260108054344 added it with defaultValue 0 and no backfill -- so seeding from it discarded the accumulated balance. This is required for the effective-date fix to work at all: the first post-switch row seeds from the last pre-switch row, which by definition only has decimal columns. Commit 57b56579 added the same fallback for PlanHoursInSeconds and PaiedOutFlexInSeconds and missed the seed; a new 2-arg overload of ApplyNettoFlexChainSecondPrecision now makes it impossible for a call site to forget it. Stamp UseOneMinuteIntervalsFrom on a false->true transition, before the one-way OR from 994c9cd4 and guarded on null so an ops backfill is not clobbered. The field is ops-only and exposed on no DTO. Gate the previously ungated SumFlexEndInSeconds write in TimePlanningFlexService, which corrupted five-minute sites, and read its seed before the decimal is mutated so the payout delta is not applied twice. ApplyRunningFlexChain now resolves the mode per row and keeps its seconds accumulator in lockstep with the decimal, so a chain spanning a mode boundary carries the balance correctly; previously the seconds accumulator went stale and a one-minute row after a five-minute row chained off garbage. Also fixes a pre-existing CI gap: ScheduleMessageReadTests ran on PRs but not on master, and it is one of only two classes exercising this code path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .github/workflows/dotnet-core-master.yml | 4 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../OneMinuteIntervalsEffectiveDateTests.cs | 296 ++++++++++++++++ .../RunningFlexChainModeBoundaryTests.cs | 318 ++++++++++++++++++ .../Helpers/OneMinuteModeTimeline.cs | 139 +++++++- .../Helpers/PlanRegistrationHelper.cs | 132 +++++--- .../TimePlanningFlexService.cs | 33 +- .../TimePlanningPlanningService.cs | 38 +-- .../TimeSettingService.cs | 7 + .../TimePlanningWorkingHoursService.cs | 181 +++++----- .../TimePlanning.Pn/TimePlanning.Pn.csproj | 2 +- 11 files changed, 980 insertions(+), 172 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index f5f510e5..eb04e03a 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -253,13 +253,13 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests" - name: f - filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests" - name: g filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServicePhoneNumberTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningWorkingHoursExportTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAbsenceRequestGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAuthGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DagsoversigtWorksheetExportTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursImportRemovedRowTests" - name: h diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 4ee1d5c9..37a5c069 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: e filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests" - name: f - filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests" - name: g filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServicePhoneNumberTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningWorkingHoursExportTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAbsenceRequestGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAuthGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DagsoversigtWorksheetExportTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursImportRemovedRowTests" - name: h diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs new file mode 100644 index 00000000..9dc32705 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; + +namespace TimePlanning.Pn.Test; + +/// +/// Pure in-memory unit tests (no DbContext) for the one-minute-intervals +/// EFFECTIVE-DATE fix. +/// +/// Background: AssignedSite.UseOneMinuteIntervals is a per-site boolean +/// with no effective-from date, so every flex recomputation re-derived a +/// worker's ENTIRE history under the site's CURRENT mode — switching it on +/// silently restated already-closed periods at one-minute precision. +/// AssignedSite.UseOneMinuteIntervalsFrom records when the flag took +/// effect; NULL means "nothing recorded" and preserves today's behaviour. +/// +/// Covered here: +/// - — the single +/// place the stored date becomes a verdict (before / on / after, date-only, +/// NULL falls through, flag-off short-circuit). +/// - — the stored date wins +/// over the AssignedSiteVersions-derived timeline; a NULL date falls through +/// to that timeline unchanged. +/// - — the per-row +/// write-time marker outranks both. +/// - — the +/// reverse seed fallback (SumFlexEndInSeconds is 0 on ~97% of rows). +/// - — the +/// false→true settings stamp and its no-clobber guard. +/// +[TestFixture] +public class OneMinuteIntervalsEffectiveDateTests +{ + private static readonly DateTime EffectiveFrom = new(2026, 6, 1, 14, 45, 0); // mid-day save + private static readonly DateTime StampedAt = new(2026, 8, 31, 10, 0, 0, DateTimeKind.Utc); + + // ---------------------------------------------------------------- // + // 1. ResolveByEffectiveDate — the single resolution expression // + // ---------------------------------------------------------------- // + + [Test] + public void EffectiveDate_Null_ReturnsNull_SoCallersFallThroughToTheTimeline() + { + Assert.Multiple(() => + { + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(true, null, new DateTime(2026, 6, 1)), + Is.Null, "Nothing recorded → the derived timeline must answer."); + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(false, null, new DateTime(2026, 6, 1)), + Is.Null); + }); + } + + [Test] + public void EffectiveDate_Set_SplitsOnTheDate_DateOnly() + { + Assert.Multiple(() => + { + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 5, 31)), + Is.False, "The day BEFORE the effective date stays 5-minute."); + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 6, 1)), + Is.True, + "A PlanRegistration.Date is a midnight anchor: an effective date saved at " + + "14:45 still governs the WHOLE of that day (date-only comparison)."); + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 6, 2)), + Is.True); + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2030, 1, 1)), + Is.True); + }); + } + + [Test] + public void EffectiveDate_Set_ButFlagOff_IsFalseEverywhere() + { + // The flag is one-way in the settings path, but an ops/raw-SQL turn-off + // must not resurrect one-minute mode from a stale recorded date. + Assert.Multiple(() => + { + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(false, EffectiveFrom, new DateTime(2026, 5, 1)), + Is.False); + Assert.That( + OneMinuteModeTimeline.ResolveByEffectiveDate(false, EffectiveFrom, new DateTime(2027, 1, 1)), + Is.False); + }); + } + + // ---------------------------------------------------------------- // + // 2. Timeline precedence — stored date beats the derived trail // + // ---------------------------------------------------------------- // + + [Test] + public void StoredEffectiveDate_OverridesTheDerivedVersionTimeline() + { + // The audit trail says the flag flipped on 2026-03-01, but ops recovered + // the real transition and recorded 2026-06-01. The stored date wins. + var timeline = new OneMinuteModeTimeline( + true, + new List<(bool, DateTime)> + { + (false, new DateTime(2026, 1, 1)), + (true, new DateTime(2026, 3, 1)) + }, + EffectiveFrom); + + Assert.Multiple(() => + { + Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 3, 15)), Is.False, + "The derived trail would say true here; the recorded date says otherwise."); + Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 5, 31)), Is.False); + Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 6, 1)), Is.True); + }); + } + + [Test] + public void NullEffectiveDate_LeavesTheDerivedTimelineUntouched() + { + var timeline = new OneMinuteModeTimeline( + true, + new List<(bool, DateTime)> + { + (false, new DateTime(2026, 1, 1)), + (true, new DateTime(2026, 3, 1)) + }); + + Assert.Multiple(() => + { + Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 2, 1)), Is.False); + Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 3, 1)), Is.True, + "With nothing recorded the AssignedSiteVersions walk still governs."); + }); + } + + // ---------------------------------------------------------------- // + // 3. Per-row precedence — the write-time marker outranks both // + // ---------------------------------------------------------------- // + + // The dbContext argument is only touched when neither the marker nor the + // recorded effective date can answer, so these cases can pass null for it. + + [Test] + public async Task RowMarker_WinsOverTheEffectiveDate() + { + var site = new AssignedSite + { + UseOneMinuteIntervals = true, + UseOneMinuteIntervalsFrom = EffectiveFrom + }; + + // Registered under one-minute mode on a date BEFORE the effective date + // (e.g. an admin re-registered the day after the flip): the marker is + // ground truth and must win. + var markedOneMinute = new PlanRegistration + { + Date = new DateTime(2026, 1, 15), + RegisteredUnderOneMinuteIntervals = true + }; + // Registered under 5-minute mode on a date AFTER the effective date. + var markedFiveMinute = new PlanRegistration + { + Date = new DateTime(2026, 9, 15), + RegisteredUnderOneMinuteIntervals = false + }; + + Assert.That( + await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, markedOneMinute), + Is.True); + Assert.That( + await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, markedFiveMinute), + Is.False); + } + + [Test] + public async Task UnmarkedRow_ResolvesFromTheEffectiveDate() + { + var site = new AssignedSite + { + UseOneMinuteIntervals = true, + UseOneMinuteIntervalsFrom = EffectiveFrom + }; + + var before = new PlanRegistration { Date = new DateTime(2026, 5, 31) }; + var onTheDay = new PlanRegistration { Date = new DateTime(2026, 6, 1) }; + var after = new PlanRegistration { Date = new DateTime(2026, 7, 1) }; + + Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, before), Is.False, + "A closed pre-switch day must NOT be recomputed at one-minute precision."); + Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, onTheDay), Is.True); + Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, after), Is.True); + } + + [Test] + public async Task NoAssignedSite_ResolvesToFiveMinute() + { + var row = new PlanRegistration { Date = new DateTime(2026, 6, 1) }; + Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, null, row), Is.False); + } + + // ---------------------------------------------------------------- // + // 4. Reverse seed fallback // + // ---------------------------------------------------------------- // + + [Test] + public void SeedFallback_NullPredecessor_IsZero() + { + Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0)); + } + + [Test] + public void SeedFallback_PopulatedSecondsWin() + { + var pre = new PlanRegistration { SumFlexEndInSeconds = 7261, SumFlexEnd = 99 }; + Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre), Is.EqualTo(7261), + "When the seconds column is populated it is the source of truth."); + } + + [Test] + public void SeedFallback_ZeroSeconds_FallsBackToTheDecimalBalance() + { + // Migration 20260108054344 added SumFlexEndInSeconds with defaultValue 0 + // and no backfill, so on ~97% of rows the real balance is only in the + // decimal. Seeding from the raw column discards the whole balance — + // which also fires on the FIRST post-switch row, whose predecessor is by + // definition a pre-switch row that only ever had decimals written. + Assert.Multiple(() => + { + Assert.That( + PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 12.5 }), + Is.EqualTo(45000)); + Assert.That( + PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = -2.25 }), + Is.EqualTo(-8100), "A negative carried balance survives the fallback."); + Assert.That( + PlanRegistrationHelper.SumFlexEndSecondsWithFallback( + new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 0 }), + Is.EqualTo(0), "A genuine zero and an unbackfilled zero agree."); + }); + } + + // ---------------------------------------------------------------- // + // 5. The settings stamp // + // ---------------------------------------------------------------- // + + [Test] + public void Stamp_FiresOnFalseToTrue() + { + var site = new AssignedSite { UseOneMinuteIntervals = false, UseOneMinuteIntervalsFrom = null }; + OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt); + Assert.That(site.UseOneMinuteIntervalsFrom, Is.EqualTo(StampedAt)); + } + + [Test] + public void Stamp_DoesNotFireWhenAlreadyTrue() + { + // UseOneMinuteIntervals is one-way (it is ORed with the incoming value), + // so every later settings save re-submits true. Stamping again here + // would move the effective date forward on every save. + var site = new AssignedSite { UseOneMinuteIntervals = true, UseOneMinuteIntervalsFrom = null }; + OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt); + Assert.That(site.UseOneMinuteIntervalsFrom, Is.Null); + } + + [Test] + public void Stamp_DoesNotOverwriteAnExistingDate() + { + // An ops script backfills recovered historical dates; a later settings + // save must not clobber one with today's date. + var backfilled = new DateTime(2025, 4, 2, 8, 0, 0, DateTimeKind.Utc); + var site = new AssignedSite + { + UseOneMinuteIntervals = false, + UseOneMinuteIntervalsFrom = backfilled + }; + OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt); + Assert.That(site.UseOneMinuteIntervalsFrom, Is.EqualTo(backfilled)); + } + + [Test] + public void Stamp_DoesNotFireWhenIncomingIsFalse() + { + var site = new AssignedSite { UseOneMinuteIntervals = false, UseOneMinuteIntervalsFrom = null }; + OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, false, StampedAt); + Assert.That(site.UseOneMinuteIntervalsFrom, Is.Null); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs new file mode 100644 index 00000000..fbe48d99 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; + +namespace TimePlanning.Pn.Test; + +/// +/// Pure in-memory unit tests (no DbContext) for +/// TimePlanningWorkingHoursService.ApplyRunningFlexChain across a +/// UseOneMinuteIntervals MODE BOUNDARY inside a single list — the production +/// scenario of a worker whose displayed period spans the site's flip. +/// +/// 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). +/// Both running accumulators are nonetheless advanced on every row so the +/// balance carries across the boundary — that hand-off is what these tests pin. +/// +/// The chain only reads logger from inside its catch blocks, so the +/// service is constructed with a substitute logger and nulls for every other +/// dependency; nothing here touches a database. +/// +[TestFixture] +public class RunningFlexChainModeBoundaryTests +{ + private TimePlanningWorkingHoursService _service = null!; + + /// A timeline that is never consulted (every row carries a marker). + private static OneMinuteModeTimeline UnusedTimeline + => new(false, Array.Empty<(bool, DateTime)>()); + + [SetUp] + public void SetUp() + { + _service = new TimePlanningWorkingHoursService( + Substitute.For>(), + dbContext: null!, + userService: null!, + localizationService: null!, + baseDbContext: null!, + options: null!, + coreHelper: null!); + } + + private static TimePlanningWorkingHoursModel FiveMinuteRow( + int dayOfMonth, double flexHours, string paidOutFlex = "0", + double sumFlexStart = 0, int sumFlexEndInSeconds = 0) + => new() + { + Date = new DateTime(2026, 6, dayOfMonth), + RegisteredUnderOneMinuteIntervals = false, + FlexHours = flexHours, + PaidOutFlex = paidOutFlex, + SumFlexStart = sumFlexStart, + SumFlexEndInSeconds = sumFlexEndInSeconds + }; + + private static TimePlanningWorkingHoursModel OneMinuteRow( + int dayOfMonth, int flexInSeconds, int paiedOutFlexInSeconds = 0, + double sumFlexStart = 0, int sumFlexStartInSeconds = 0) + => new() + { + Date = new DateTime(2026, 6, dayOfMonth), + RegisteredUnderOneMinuteIntervals = true, + FlexInSeconds = flexInSeconds, + PaidOutFlex = "0", + PaiedOutFlexInSeconds = paiedOutFlexInSeconds, + SumFlexStart = sumFlexStart, + SumFlexStartInSeconds = sumFlexStartInSeconds + }; + + // ------------------------------------------------------------------ // + // 1. Five-minute rows followed by one-minute rows (the real flip) // + // ------------------------------------------------------------------ // + + [Test] + public void FiveMinuteThenOneMinute_CarriesTheBalanceToTheSecond() + { + // 2.00 h opening, +1.50 h, -0.50 h → 3.00 h at the boundary, + // then +30 m 37 s and -37 s in one-minute mode. + var rows = new List + { + FiveMinuteRow(1, flexHours: 1.5, sumFlexStart: 2.0), + FiveMinuteRow(2, flexHours: -0.5), + OneMinuteRow(3, flexInSeconds: 1837), + OneMinuteRow(4, flexInSeconds: -37) + }; + + _service.ApplyRunningFlexChain(rows, UnusedTimeline); + + Assert.Multiple(() => + { + Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3.5).Within(1e-9)); + Assert.That(rows[1].SumFlexEnd, Is.EqualTo(3.0).Within(1e-9)); + + // The hand-off: the first post-flip row opens on exactly the + // pre-flip closing balance, in seconds. + Assert.That(rows[2].SumFlexStartInSeconds, Is.EqualTo(10800), + "3.00 h carried across the boundary as 10800 s."); + Assert.That(rows[2].SumFlexEndInSeconds, Is.EqualTo(12637), + "10800 + 1837 — the 37 s survives the boundary."); + Assert.That(rows[3].SumFlexStartInSeconds, Is.EqualTo(12637)); + Assert.That(rows[3].SumFlexEndInSeconds, Is.EqualTo(12600)); + Assert.That(rows[3].SumFlexEnd, Is.EqualTo(3.5).Within(1e-9)); + + // Lockstep on every one-minute row: the double is the exact + // back-derivation of the integer source of truth. + 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). + Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(0)); + Assert.That(rows[1].SumFlexEndInSeconds, Is.EqualTo(0)); + }); + } + + // ------------------------------------------------------------------ // + // 2. One-minute rows followed by five-minute rows (the reverse) // + // ------------------------------------------------------------------ // + + /// + /// The seconds → double hand-off is LOSSLESS: the five-minute row opens on + /// the full-precision SumFlexEndInSeconds / 3600.0, not on a + /// whole-minute or 2-decimal truncation of it. + /// + /// The five-minute row's own OUTPUT is still Math.Round(…, 2) — that + /// is the pre-existing legacy formula for every five-minute row and is not + /// changed here — so a subsequent return to one-minute mode rebuilds the + /// seconds accumulator from that rounded double and can lose up to 18 s. + /// That is pinned below rather than left silent. It does not arise in + /// production: UseOneMinuteIntervals is one-way, so a chain never + /// crosses back from one-minute to five-minute. + /// + [Test] + public void OneMinuteThenFiveMinute_HandsOffFullPrecision() + { + // 1 h 0 m 37 s — deliberately not a whole number of minutes, so any + // truncation in the hand-off would show. + var rows = new List + { + OneMinuteRow(1, flexInSeconds: 3637), + FiveMinuteRow(2, flexHours: 0.5), + OneMinuteRow(3, flexInSeconds: 0) + }; + + _service.ApplyRunningFlexChain(rows, UnusedTimeline); + + Assert.Multiple(() => + { + Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(3637)); + Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3637 / 3600.0)); + + Assert.That(rows[1].SumFlexStart, Is.EqualTo(3637 / 3600.0), + "The five-minute row opens on the EXACT seconds balance — the " + + "hand-off applies no rounding of its own."); + Assert.That(rows[1].SumFlexEnd, Is.EqualTo(1.51).Within(1e-9), + "Its own output is 2-decimal rounded by the pre-existing legacy " + + "formula (1.5102777… h → 1.51)."); + + Assert.That(rows[2].SumFlexStartInSeconds, Is.EqualTo(5436), + "Returning to one-minute mode rebuilds seconds from the rounded " + + "double: 5437 s becomes 5436 s. Inherent to the legacy " + + "five-minute rounding; unreachable in production because the " + + "flag is one-way."); + }); + } + + // ------------------------------------------------------------------ // + // 3. Regression guard: a uniformly five-minute list is unchanged // + // ------------------------------------------------------------------ // + + /// + /// The common case. Expected values are the pre-change legacy formulas + /// computed by hand: + /// row 0: SumFlexStart = Round(1.234567, 2) = 1.23 + /// 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. + /// + [Test] + public void UniformlyFiveMinute_MatchesTheLegacyFormulasAndWritesNoSeconds() + { + const int sentinel = 424242; + var rows = new List + { + FiveMinuteRow(1, flexHours: 2.5, paidOutFlex: "0.25", + sumFlexStart: 1.234567, sumFlexEndInSeconds: sentinel), + FiveMinuteRow(2, flexHours: -1.1, sumFlexEndInSeconds: sentinel), + FiveMinuteRow(3, flexHours: 0.333333, paidOutFlex: "0.5", + sumFlexEndInSeconds: sentinel) + }; + foreach (var row in rows) + { + row.SumFlexStartInSeconds = sentinel; + } + + _service.ApplyRunningFlexChain(rows, UnusedTimeline); + + Assert.Multiple(() => + { + Assert.That(rows[0].SumFlexStart, Is.EqualTo(1.23).Within(1e-9)); + Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3.48).Within(1e-9)); + Assert.That(rows[1].SumFlexStart, Is.EqualTo(3.48).Within(1e-9)); + Assert.That(rows[1].SumFlexEnd, Is.EqualTo(2.38).Within(1e-9)); + Assert.That(rows[2].SumFlexStart, Is.EqualTo(2.38).Within(1e-9)); + Assert.That(rows[2].SumFlexEnd, Is.EqualTo(2.21).Within(1e-9)); + + foreach (var row in rows) + { + Assert.That(row.SumFlexStartInSeconds, Is.EqualTo(sentinel)); + Assert.That(row.SumFlexEndInSeconds, Is.EqualTo(sentinel)); + } + }); + } + + // ------------------------------------------------------------------ // + // 4. The anchor seed — the balance-collapse mechanism itself // + // ------------------------------------------------------------------ // + + [Test] + public void OneMinuteAnchor_WithZeroSecondsColumn_StartsFromTheDecimalBalance() + { + // SumFlexEndInSeconds / SumFlexStartInSeconds are 0 on ~97% of rows + // (migration 20260108054344, defaultValue 0, no backfill). Seeding the + // chain from the raw column discarded the whole 4.25 h opening balance. + var rows = new List + { + OneMinuteRow(1, flexInSeconds: 900, sumFlexStart: 4.25, sumFlexStartInSeconds: 0) + }; + + _service.ApplyRunningFlexChain(rows, UnusedTimeline); + + Assert.Multiple(() => + { + Assert.That(rows[0].SumFlexStartInSeconds, Is.EqualTo(15300), + "4.25 h — NOT 0."); + Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(16200)); + Assert.That(rows[0].SumFlexEnd, Is.EqualTo(4.5).Within(1e-9)); + }); + } + + [Test] + public void OneMinuteAnchor_WithPopulatedSecondsColumn_IgnoresTheDecimal() + { + var rows = new List + { + OneMinuteRow(1, flexInSeconds: 0, sumFlexStart: 99, sumFlexStartInSeconds: 7200) + }; + + _service.ApplyRunningFlexChain(rows, UnusedTimeline); + + Assert.Multiple(() => + { + Assert.That(rows[0].SumFlexStartInSeconds, Is.EqualTo(7200), + "A populated seconds column is the source of truth."); + Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(7200)); + }); + } + + // ------------------------------------------------------------------ // + // 5. End to end: unmarked rows split by the site's effective date // + // ------------------------------------------------------------------ // + + /// + /// The full production shape: legacy rows carrying NO write-time marker, + /// with the boundary supplied by the site's recorded + /// UseOneMinuteIntervalsFrom (2026-06-01). Days before it must stay + /// on 5-minute rules; the flip day onwards runs in seconds. + /// + [Test] + public void UnmarkedRows_SplitByTheSitesEffectiveDate() + { + var timeline = new OneMinuteModeTimeline( + currentFlag: true, + versionFlags: Array.Empty<(bool, DateTime)>(), + effectiveFrom: new DateTime(2026, 6, 1, 14, 45, 0)); + + var before = new TimePlanningWorkingHoursModel + { + Date = new DateTime(2026, 5, 30), + RegisteredUnderOneMinuteIntervals = null, + FlexHours = 1.0, + PaidOutFlex = "0", + SumFlexStart = 1.0 + }; + var onTheFlipDay = new TimePlanningWorkingHoursModel + { + Date = new DateTime(2026, 6, 1), + RegisteredUnderOneMinuteIntervals = null, + FlexInSeconds = 61, + PaidOutFlex = "0" + }; + var rows = new List { before, onTheFlipDay }; + + _service.ApplyRunningFlexChain(rows, timeline); + + Assert.Multiple(() => + { + Assert.That(before.SumFlexEnd, Is.EqualTo(2.0).Within(1e-9), + "Pre-flip day recomputed under 5-minute rules."); + Assert.That(before.SumFlexEndInSeconds, Is.EqualTo(0), + "…and its seconds DTO field left untouched."); + Assert.That(onTheFlipDay.SumFlexStartInSeconds, Is.EqualTo(7200), + "The flip day opens on the pre-flip balance."); + Assert.That(onTheFlipDay.SumFlexEndInSeconds, Is.EqualTo(7261)); + }); + } +} 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 c35cdfad..e04850cd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microting.TimePlanningBase.Infrastructure.Data; using AssignedSite = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using PlanRegistration = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; namespace TimePlanning.Pn.Infrastructure.Helpers; @@ -54,6 +55,27 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; /// earliest possible un-audited flip point. Audited history before that /// date is preserved; for sites flipped through the API this is a no-op. /// +/// Authoritative override: AssignedSite.UseOneMinuteIntervalsFrom. +/// The derived timeline above is a RECONSTRUCTION; when ops (or the +/// settings save in TimeSettingService.UpdateAssignedSite) has +/// recorded the date the flag actually took effect, that stored date is the +/// truth and the reconstruction is not consulted at all: +/// UseOneMinuteIntervals && rowDate >= UseOneMinuteIntervalsFrom +/// (date-only, same granularity rule as above). A NULL column means "nothing +/// recorded" and falls through to the derived timeline, which keeps today's +/// behaviour for every site ops has not backfilled. +/// +/// Full per-row precedence (see ): +/// 1. PlanRegistration.RegisteredUnderOneMinuteIntervals — the +/// write-time marker, ground truth for rows that carry one. +/// 2. UseOneMinuteIntervalsFrom — the stored effective date. +/// 3. the AssignedSiteVersions-derived timeline. +/// +/// The class also OWNS THE WRITE SIDE of that column: +/// is what records the date when the +/// settings save flips the flag on, so the read rule and the write rule cannot +/// drift apart. +/// /// Cost: ONE query per site (); lookups are pure /// in-memory. Build once per site per request scope — never per row. /// @@ -61,6 +83,15 @@ public sealed class OneMinuteModeTimeline { private readonly bool _initialValue; + /// The site's CURRENT flag (also the effective-date verdict's value). + private readonly bool _currentFlag; + + /// + /// The authoritative date the current flag took effect, when recorded; + /// NULL means "not recorded" and the derived timeline is used instead. + /// + private readonly DateTime? _effectiveFrom; + /// Date-only change points in save order (date, value-from-that-date). private readonly List<(DateTime Date, bool Value)> _changePoints; @@ -72,11 +103,17 @@ public sealed class OneMinuteModeTimeline /// no-version-rows fallback AND the divergence-correction authority (see /// class docs): when the trail does not end on this value, the current /// flag takes over from the last audited save date. + /// is the site's recorded + /// UseOneMinuteIntervalsFrom; when non-null it OVERRIDES the derived + /// timeline entirely (see ). /// internal OneMinuteModeTimeline( bool currentFlag, - IReadOnlyList<(bool UseOneMinuteIntervals, DateTime SavedAt)> versionFlags) + IReadOnlyList<(bool UseOneMinuteIntervals, DateTime SavedAt)> versionFlags, + DateTime? effectiveFrom = null) { + _currentFlag = currentFlag; + _effectiveFrom = effectiveFrom; _changePoints = new List<(DateTime, bool)>(); if (versionFlags == null || versionFlags.Count == 0) @@ -113,11 +150,19 @@ internal OneMinuteModeTimeline( /// /// Builds the timeline for one AssignedSite with a single /// AssignedSiteVersions query. An unsaved entity (Id == 0) or a site - /// without audit rows yields a constant timeline of the current flag. + /// without audit rows yields a constant timeline of the current flag; + /// a null site (no AssignedSite row for the worker) yields a constant + /// 5-minute timeline, so callers never need their own empty-timeline + /// fallback. /// public static async Task BuildAsync( - TimePlanningPnDbContext dbContext, AssignedSite assignedSite) + TimePlanningPnDbContext dbContext, AssignedSite? assignedSite) { + if (assignedSite == null) + { + return new OneMinuteModeTimeline(false, Array.Empty<(bool, DateTime)>()); + } + var versionFlags = await dbContext.AssignedSiteVersions .AsNoTracking() .Where(x => x.AssignedSiteId == assignedSite.Id) @@ -132,15 +177,99 @@ public static async Task BuildAsync( assignedSite.UseOneMinuteIntervals, versionFlags .Select(x => (x.UseOneMinuteIntervals, x.UpdatedAt ?? x.CreatedAt)) - .ToList()); + .ToList(), + assignedSite.UseOneMinuteIntervalsFrom); + } + + /// + /// The ONE place the stored effective date is turned into a verdict. + /// Returns null when nothing is recorded ( + /// is NULL) so the caller falls through to the derived timeline; otherwise + /// the flag applies only from that date onwards. DATE-ONLY comparison — a + /// PlanRegistration.Date is a midnight anchor with no time-of-day, + /// matching the timeline's own granularity rule. + /// + internal static bool? ResolveByEffectiveDate( + bool currentFlag, DateTime? effectiveFrom, DateTime rowDate) + => effectiveFrom == null + ? null + : currentFlag && rowDate.Date >= effectiveFrom.Value.Date; + + /// + /// Records WHEN one-minute intervals took effect, on the false→true + /// transition only. Must be called BEFORE the caller ORs the incoming value + /// into the stored flag: UseOneMinuteIntervals is deliberately + /// one-way (commit 994c9cd4), so after the OR a real transition is + /// indistinguishable from "was already true". + /// + /// The UseOneMinuteIntervalsFrom == null guard is required: an ops + /// backfill of recovered historical dates must not be clobbered with + /// today's date by an unrelated later settings save. The column is ops-only + /// — written by script or by this stamp, never exposed on a DTO. + /// + public static void StampEffectiveDateOnEnable( + AssignedSite dbAssignedSite, bool incomingUseOneMinuteIntervals, DateTime now) + { + if (!dbAssignedSite.UseOneMinuteIntervals + && incomingUseOneMinuteIntervals + && dbAssignedSite.UseOneMinuteIntervalsFrom == null) + { + dbAssignedSite.UseOneMinuteIntervalsFrom = now; + } + } + + /// + /// Resolves the mode for ONE row under the full precedence (write-time + /// marker → stored effective date → derived timeline), querying + /// AssignedSiteVersions only when neither of the first two can answer. + /// Use this from calc paths that hold a single row; loops that already + /// build a timeline should keep using + /// row.RegisteredUnderOneMinuteIntervals ?? timeline.WasOneMinuteAt(row.Date), + /// which carries the same precedence because + /// consults the effective date first. + /// + /// NEVER call this in a loop: on a legacy row of an un-backfilled site it + /// falls through to , so a per-row call is the + /// exact N+1 this class exists to avoid. Build a timeline once instead. + /// + public static async Task ResolveRowModeAsync( + TimePlanningPnDbContext dbContext, AssignedSite? assignedSite, PlanRegistration row) + { + if (row.RegisteredUnderOneMinuteIntervals.HasValue) + { + return row.RegisteredUnderOneMinuteIntervals.Value; + } + + if (assignedSite == null) + { + return false; + } + + var byEffectiveDate = ResolveByEffectiveDate( + assignedSite.UseOneMinuteIntervals, assignedSite.UseOneMinuteIntervalsFrom, row.Date); + if (byEffectiveDate.HasValue) + { + return byEffectiveDate.Value; + } + + var timeline = await BuildAsync(dbContext, assignedSite); + return timeline.WasOneMinuteAt(row.Date); } /// /// The UseOneMinuteIntervals value in force on - /// (date-only comparison; the time component is ignored). + /// (date-only comparison; the time component is ignored). The site's + /// recorded UseOneMinuteIntervalsFrom wins when present; only when + /// nothing is recorded does the AssignedSiteVersions-derived walk answer. /// public bool WasOneMinuteAt(DateTime rowDate) { + var byEffectiveDate = ResolveByEffectiveDate(_currentFlag, _effectiveFrom, rowDate); + if (byEffectiveDate.HasValue) + { + return byEffectiveDate.Value; + } + var date = rowDate.Date; var value = _initialValue; // Walk ALL change points in save order (no early break): the LAST save 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 0421f292..377c5b48 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -457,6 +457,38 @@ public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIn return (int)(totalSeconds / 60); // round down to whole minutes } + /// + /// Reads an *InSeconds column, falling back to its legacy + /// double hour sibling when the column is still 0. + /// + /// Every *InSeconds column was added by a migration with + /// defaultValue: 0 and NO backfill (SumFlexEndInSeconds by + /// 20260108054344), so on the overwhelming majority of historical rows the + /// column reads 0 while the real value lives in the decimal. Taking the + /// column at face value silently substitutes zero for a real balance. + /// + /// A genuine zero and an unbackfilled zero are indistinguishable, which is + /// harmless: both fall back to the decimal, and a genuinely-zero row has a + /// zero decimal too. + /// + public static int SecondsOrDecimalFallback(int seconds, double hours) + => seconds != 0 ? seconds : (int)Math.Round(hours * 3600); + + /// + /// Seeds the running flex chain from the preceding day's closing balance, + /// in seconds, via ; 0 when there is + /// no preceding row. + /// + /// The fallback is load-bearing at a one-minute mode transition: the first + /// post-switch row seeds from the last PRE-switch row, which by definition + /// only ever had the decimal columns written. + /// + public static int SumFlexEndSecondsWithFallback(PlanRegistration? preTimePlanning) + => preTimePlanning == null + ? 0 + : SecondsOrDecimalFallback( + preTimePlanning.SumFlexEndInSeconds, preTimePlanning.SumFlexEnd); + /// /// Phase 2 — write the second-precision NettoHours / Flex / SumFlex chain. /// @@ -491,6 +523,18 @@ public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIn /// 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) { @@ -498,18 +542,11 @@ public static void ApplyNettoFlexChainSecondPrecision(PlanRegistration pr, pr.NettoHoursInSeconds = (int)nettoSeconds; pr.NettoHours = nettoSeconds / 3600.0; - // Punch-clock / scheduled days populate the double PlanHours but leave - // PlanHoursInSeconds at 0. Fall back to PlanHours * 3600 so flex is - // computed against the real plan instead of treating it as 0. - var planHoursSeconds = pr.PlanHoursInSeconds != 0 - ? pr.PlanHoursInSeconds - : (int)Math.Round(pr.PlanHours * 3600); - // Production writers populate only the double PaiedOutFlex and leave - // PaiedOutFlexInSeconds at 0. Fall back to PaiedOutFlex * 3600 so a - // paid-out flex is subtracted instead of being treated as 0. - var paiedOutFlexSeconds = pr.PaiedOutFlexInSeconds != 0 - ? pr.PaiedOutFlexInSeconds - : (int)Math.Round(pr.PaiedOutFlex * 3600); + // Punch-clock / scheduled days and production writers populate only the + // doubles; the *InSeconds siblings stay 0. See SecondsOrDecimalFallback. + var planHoursSeconds = SecondsOrDecimalFallback(pr.PlanHoursInSeconds, pr.PlanHours); + var paiedOutFlexSeconds = + SecondsOrDecimalFallback(pr.PaiedOutFlexInSeconds, pr.PaiedOutFlex); // Mirror the flag-off override semantics: // Flex = (override ? NettoHoursOverride : NettoHours) - PlanHours @@ -560,12 +597,11 @@ public static async Task UpdatePlanRegistrationsInPer // Load the message catalog once (no N+1) so each day can resolve its // localized label without re-querying per row. var messagesById = await dbContext.Messages.AsNoTracking().ToDictionaryAsync(m => m.Id); - // Stage 3 tick-exact parity: resolve the UseOneMinuteIntervals mode that - // was in force when each row was REGISTERED (from AssignedSiteVersions — - // one query, in-memory lookups) so the Start/Stop display projection - // below renders tick rows from ids and one-minute rows from stamps, - // regardless of the site's CURRENT flag. Write/calc forks in this method - // intentionally keep using dbAssignedSite.UseOneMinuteIntervals. + // ONE query, in-memory lookups: resolves the mode that was in force when + // each row was REGISTERED. EVERY mode fork in this method reads the + // resulting per-row `rowIsOneMinute`, never the site's CURRENT flag — + // recomputing closed days under a newly-enabled one-minute flag is what + // silently rewrote historical balances. See OneMinuteModeTimeline. var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, dbAssignedSite); var toDay = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, 0, 0, 0); // var dayOfPayment = toDay.Day >= settingsDayOfPayment @@ -577,10 +613,8 @@ public static async Task UpdatePlanRegistrationsInPer var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id); var midnight = new DateTime(planRegistration.Date.Year, planRegistration.Date.Month, planRegistration.Date.Day, 0, 0, 0); - // Mode at registration — display-only. The write-time marker (stamped - // by every Start/Stop-writing save from the site's then-current flag) - // is authoritative; the AssignedSiteVersions timeline is the fallback - // for legacy rows written before the marker existed (marker NULL). + // Mode at registration: the write-time marker when the row has one, + // else the timeline (effective date, else the audit trail). var rowIsOneMinute = planRegistration.RegisteredUnderOneMinuteIntervals ?? oneMinuteTimeline.WasOneMinuteAt(planRegistration.Date); @@ -594,7 +628,7 @@ public static async Task UpdatePlanRegistrationsInPer // the int Id is corrected and StartedAt is backfilled from it. // When the flag is on but StartedAt is null, fall through to the // backfill so legacy rows without precise stamps still get one. - if (dbAssignedSite.UseOneMinuteIntervals && planRegistration.Start1StartedAt.HasValue) + if (rowIsOneMinute && planRegistration.Start1StartedAt.HasValue) { // Phase 1: precise DateTime stamp wins; do NOT overwrite it // with the 5-minute snap derived from Start1Id. @@ -610,7 +644,7 @@ public static async Task UpdatePlanRegistrationsInPer // FIXME: This is a workaround, it should be removed when the frontend is fixed. planRegistration.Stop1Id /= 5 + 1; // Phase 1: same fork as Start1 above for the stop stamp. - if (dbAssignedSite.UseOneMinuteIntervals && planRegistration.Stop1StoppedAt.HasValue) + if (rowIsOneMinute && planRegistration.Stop1StoppedAt.HasValue) { // Phase 1: precise DateTime stamp wins; do NOT overwrite it // with the 5-minute snap derived from Stop1Id. @@ -679,15 +713,12 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, run the - // SumFlex chain in seconds (source of truth) and - // back-derive doubles. Flag-off path stays byte-identical. - if (dbAssignedSite.UseOneMinuteIntervals) + // Fork on the mode AT REGISTRATION, not the site's current + // flag — see OneMinuteModeTimeline for why. + if (rowIsOneMinute) { ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else if (preTimePlanning != null) { @@ -1010,15 +1041,12 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, run the - // SumFlex chain in seconds (source of truth) and - // back-derive doubles. Flag-off path stays byte-identical. - if (dbAssignedSite.UseOneMinuteIntervals) + // Fork on the mode AT REGISTRATION, not the site's current + // flag — see OneMinuteModeTimeline for why. + if (rowIsOneMinute) { ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else if (preTimePlanning != null) { @@ -1313,7 +1341,7 @@ await dbContext.PlanRegistrations.AsNoTracking() Pause5StoppedAt = planRegistration.Pause5StoppedAt }; - planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, dbAssignedSite.UseOneMinuteIntervals); + planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, rowIsOneMinute); // planningModel.PauseMinutes = planningModel.PauseMinutes > 0 ? planningModel.PauseMinutes - 5 : 0; @@ -1378,6 +1406,10 @@ 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); var tainted = false; // foreach (var plan in planningsInPeriod) // { @@ -1434,15 +1466,12 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, run the - // SumFlex chain in seconds (source of truth) and - // back-derive doubles. Flag-off path stays byte-identical. - if (dbAssignedSite.UseOneMinuteIntervals) + // Fork on the mode AT REGISTRATION, not the site's current + // flag — see OneMinuteModeTimeline for why. + if (rowIsOneMinute) { ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else if (preTimePlanning != null) { @@ -1752,15 +1781,12 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, run the - // SumFlex chain in seconds (source of truth) and - // back-derive doubles. Flag-off path stays byte-identical. - if (dbAssignedSite.UseOneMinuteIntervals) + // Fork on the mode AT REGISTRATION, not the site's current + // flag — see OneMinuteModeTimeline for why. + if (rowIsOneMinute) { ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else if (preTimePlanning != null) { 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 dfdcb8dd..a67e2c85 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs @@ -42,6 +42,7 @@ namespace TimePlanning.Pn.Services.TimePlanningFlexService; using Microting.eFormApi.BasePn.Infrastructure.Models.Common; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanningLocalizationService; /// @@ -248,19 +249,43 @@ private async Task UpdatePlanning(PlanRegistration planRegistration, planRegistration.CommentOfficeAll = model.CommentOfficeAll; planRegistration.CommentOffice = model.CommentOffice; + var assignedSite = await dbContext.AssignedSites + .AsNoTracking() + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.SiteId == planRegistration.SdkSitId); + // PaiedOutFlexInSeconds is the source the flag-on flex chain // (PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision / // TimePlanningWorkingHoursService.ApplyRunningFlexChain) subtracts. Only ever // updating the legacy double below would leave that column stale, so keep it // in lockstep here too. Old value falls back to the double the same way those // chains do, since this row may itself have only ever had the double set. - var oldPaiedOutFlexSeconds = planRegistration.PaiedOutFlexInSeconds != 0 - ? planRegistration.PaiedOutFlexInSeconds - : (int)Math.Round(planRegistration.PaiedOutFlex * 3600); + var oldPaiedOutFlexSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback( + planRegistration.PaiedOutFlexInSeconds, planRegistration.PaiedOutFlex); var newPaiedOutFlexSeconds = (int)Math.Round(model.PaidOutFlex * 3600); + // SumFlexEndInSeconds is the source of truth ONLY for rows registered + // under one-minute mode; on 5-minute rows it is deliberately left at 0 + // (the decimal SumFlexEnd is the balance there), and ops relies on that + // zero as a forensic signal. Writing an unconditional delta here both + // corrupted 5-minute rows and — because the column is 0 on ~97% of rows + // while the decimal holds the real balance — produced a NEGATIVE value. + // The seed must be read BEFORE the decimal SumFlexEnd is adjusted below, + // or the fallback would pick up the already-adjusted balance and apply + // the payout delta twice. + var rowIsOneMinute = await OneMinuteModeTimeline.ResolveRowModeAsync( + dbContext, assignedSite, planRegistration); + var oldSumFlexEndSeconds = + PlanRegistrationHelper.SumFlexEndSecondsWithFallback(planRegistration); + planRegistration.SumFlexEnd += planRegistration.PaiedOutFlex - model.PaidOutFlex; - planRegistration.SumFlexEndInSeconds += oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds; + + if (rowIsOneMinute) + { + planRegistration.SumFlexEndInSeconds = + oldSumFlexEndSeconds + oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds; + } + planRegistration.PaiedOutFlex = model.PaidOutFlex; planRegistration.PaiedOutFlexInSeconds = newPaiedOutFlexSeconds; planRegistration.UpdatedByUserId = userService.UserId; 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 b0e29e04..9ae36c00 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -1076,9 +1076,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planning, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planning, preTimePlanning); } else { @@ -1130,6 +1128,9 @@ 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 = @@ -1140,15 +1141,13 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, replay the - // SumFlex chain through subsequent days using *InSeconds as - // the source of truth so accumulated rounding does not drift. - if (assignedSite != null && assignedSite.UseOneMinuteIntervals) + // These are OTHER, already-registered rows, so fork on the mode + // AT REGISTRATION — see OneMinuteModeTimeline. + if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals + ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date)) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planningAfterThisPlanning, - preTimePlanningAfterThisPlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanningAfterThisPlanning != null); + planningAfterThisPlanning, preTimePlanningAfterThisPlanning); } else if (preTimePlanningAfterThisPlanning != null) { @@ -1471,9 +1470,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planning, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planning, preTimePlanning); } else { @@ -1504,6 +1501,9 @@ 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 = @@ -1514,15 +1514,13 @@ await dbContext.PlanRegistrations.AsNoTracking() .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); - // Phase 2: when UseOneMinuteIntervals is on, replay the - // SumFlex chain through subsequent days using *InSeconds as - // the source of truth so accumulated rounding does not drift. - if (assignedSite != null && assignedSite.UseOneMinuteIntervals) + // These are OTHER, already-registered rows, so fork on the mode + // AT REGISTRATION — see OneMinuteModeTimeline. + if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals + ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date)) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planningAfterThisPlanning, - preTimePlanningAfterThisPlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanningAfterThisPlanning != null); + planningAfterThisPlanning, preTimePlanningAfterThisPlanning); } else if (preTimePlanningAfterThisPlanning != null) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index 7bec78d5..6264e62f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -982,6 +982,13 @@ public async Task UpdateAssignedSite(Infrastructure.Models.Sett dbAssignedSite.AllowPersonalTimeRegistration = site.AllowPersonalTimeRegistration; dbAssignedSite.AllowAcceptOfPlannedHours = site.AllowAcceptOfPlannedHours; dbAssignedSite.Resigned = site.Resigned; + // Record WHEN one-minute intervals took effect, so every later flex + // recomputation keeps pre-switch days on 5-minute rules instead of + // restating already-closed SumFlexEnd balances at one-minute precision. + // MUST run BEFORE the one-way OR on the next line — see the helper. + OneMinuteModeTimeline.StampEffectiveDateOnEnable( + dbAssignedSite, site.UseOneMinuteIntervals, DateTime.UtcNow); + dbAssignedSite.UseOneMinuteIntervals = dbAssignedSite.UseOneMinuteIntervals || site.UseOneMinuteIntervals; dbAssignedSite.UsePunchClock = site.UsePunchClock; dbAssignedSite.UseDetailedPauseEditing = site.UseDetailedPauseEditing; 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 ed82fbd4..2987148c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -98,12 +98,9 @@ public async Task>> Inde .AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstOrDefaultAsync(x => x.SiteId == model.SiteId); - var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false; // Stage 3 tick-exact parity: per-row mode-at-registration from the // AssignedSiteVersions audit trail (one query; in-memory lookups). - var oneMinuteTimeline = assignedSite != null - ? await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite) - : new OneMinuteModeTimeline(false, Array.Empty<(bool, DateTime)>()); + var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite); var timePlanningRequest = dbContext.PlanRegistrations .AsNoTracking() @@ -270,10 +267,13 @@ public async Task>> Inde continue; } + // Mode AT REGISTRATION — see OneMinuteModeTimeline. + var pauseRowIsOneMinute = tp.RegisteredUnderOneMinuteIntervals + ?? oneMinuteTimeline.WasOneMinuteAt(tp.Date); tp.Shift1PauseMinutes = - PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 1, useOneMinuteIntervals) / 60; + PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 1, pauseRowIsOneMinute) / 60; tp.Shift2PauseMinutes = - PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 2, useOneMinuteIntervals) / 60; + PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 2, pauseRowIsOneMinute) / 60; } } @@ -287,6 +287,9 @@ 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); // lastPlanning.Date = new DateTime(lastPlanning.Date.Year, lastPlanning.Date.Month, lastPlanning.Date.Day, 0, 0, 0); @@ -315,10 +318,10 @@ public async Task>> Inde // materialized PlanRegistration already in scope, so this reuses // ComputeShiftPauseSeconds with no extra query (no N+1). Shift1PauseMinutes = lastPlanning != null - ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 1, useOneMinuteIntervals) / 60 + ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 1, lastPlanningIsOneMinute) / 60 : 0, Shift2PauseMinutes = lastPlanning != null - ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 2, useOneMinuteIntervals) / 60 + ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 2, lastPlanningIsOneMinute) / 60 : 0, Shift3Start = lastPlanning?.Start3Id, Shift3Stop = lastPlanning?.Stop3Id, @@ -348,7 +351,7 @@ public async Task>> Inde NettoHoursInSeconds = lastPlanning?.NettoHoursInSeconds ?? 0, FlexInSeconds = lastPlanning?.FlexInSeconds ?? 0, SumFlexStartInSeconds = lastPlanning?.SumFlexStartInSeconds ?? 0, - SumFlexEndInSeconds = lastPlanning?.SumFlexEndInSeconds ?? 0, + SumFlexEndInSeconds = PlanRegistrationHelper.SumFlexEndSecondsWithFallback(lastPlanning), PaiedOutFlexInSeconds = lastPlanning?.PaiedOutFlexInSeconds ?? 0, Message = lastPlanning?.MessageId, CommentWorker = lastPlanning?.WorkerComment?.Replace("\r", "
"), @@ -423,8 +426,10 @@ public async Task>> Inde // Single source of truth for the running flex balance rendered by // both this web grid AND the mobile period-status hero - // (CalculateHoursSummary) — see ApplyRunningFlexChain. - ApplyRunningFlexChain(timePlannings, useOneMinuteIntervals); + // (CalculateHoursSummary) — see ApplyRunningFlexChain. Forked PER ROW + // on the mode at registration so a period spanning a one-minute flip + // keeps recomputing its pre-flip days under 5-minute rules. + ApplyRunningFlexChain(timePlannings, oneMinuteTimeline); return new OperationDataResult>( true, @@ -454,7 +459,8 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr var assignedSite = await dbContext.AssignedSites .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstOrDefaultAsync(x => x.SiteId == model.SiteId); - var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false; + // ONE query for the whole cascade below — never per row. + var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite); var first = true; foreach (var planning in model.Plannings) { @@ -505,12 +511,13 @@ await dbContext.PlanRegistrations.AsNoTracking() // so the double SumFlexEnd drifted from the seconds chain and the // mobile summary (which read the double) disagreed with the web // grid (which recomputes from seconds). - if (useOneMinuteIntervals) + // Mode AT REGISTRATION for THIS later row — see + // OneMinuteModeTimeline. + if (planRegistration.RegisteredUnderOneMinuteIntervals + ?? cascadeTimeline.WasOneMinuteAt(planRegistration.Date)) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else { @@ -879,7 +886,9 @@ public async Task> CalculateH .AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstOrDefaultAsync(x => x.SiteId == sdkSite.MicrotingUid); - var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false; + // Per-row mode-at-registration, mirroring Index so the mobile hero and + // the web grid stay in agreement across a one-minute flip. + var summaryTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite); // Anchor = last non-removed planning strictly before the period; carries // the opening balance into the chain. Matches Index's prePlanning anchor @@ -904,7 +913,8 @@ public async Task> CalculateH FlexInSeconds = x.FlexInSeconds, SumFlexStartInSeconds = x.SumFlexStartInSeconds, SumFlexEndInSeconds = x.SumFlexEndInSeconds, - PaiedOutFlexInSeconds = x.PaiedOutFlexInSeconds + PaiedOutFlexInSeconds = x.PaiedOutFlexInSeconds, + RegisteredUnderOneMinuteIntervals = x.RegisteredUnderOneMinuteIntervals }; var inRangeRows = planRegistrations @@ -919,7 +929,7 @@ public async Task> CalculateH } chainRows.AddRange(inRangeRows); - ApplyRunningFlexChain(chainRows, useOneMinuteIntervals); + ApplyRunningFlexChain(chainRows, summaryTimeline); // Difference = the last in-range day's recomputed SumFlexEnd (seconds // chain), NOT a raw column read. When the period itself has no rows, @@ -978,73 +988,83 @@ private static int PaiedOutFlexSecondsWithFallback(TimePlanningWorkingHoursModel double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture) * 3600); } + /// + /// The legacy double paid-out-flex operand: the culture-formatted + /// PaidOutFlex string parsed to hours (0 when absent). Distinct from + /// , which prefers the integer + /// seconds column — the 5-minute branch must keep reading only the string. + /// + private static double PaidOutFlexHours(TimePlanningWorkingHoursModel model) + => string.IsNullOrEmpty(model.PaidOutFlex) + ? 0 + : double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture); + /// /// 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 /// by both (web grid) and /// (mobile period-status hero) so the two never disagree. /// - /// For UseOneMinuteIntervals sites the chain runs in the integer - /// *InSeconds columns (the source of truth) and back-derives the legacy - /// double SumFlex* fields via /3600.0; otherwise it runs in the - /// legacy rounded doubles. Behaviour is byte-identical to the loop previously - /// inlined in . + /// The mode is resolved PER ROW — never from the site's current flag — via + /// , so a period spanning a one-minute + /// flip keeps recomputing its pre-flip days under 5-minute rules instead of + /// silently restating closed balances. + /// + /// 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 + /// are nonetheless kept in lockstep after every row, so the balance carries + /// correctly across a mode boundary in either direction. /// - private void ApplyRunningFlexChain( - List timePlannings, bool useOneMinuteIntervals) + internal void ApplyRunningFlexChain( + List timePlannings, OneMinuteModeTimeline timeline) { var j = 0; double sumFlexEnd = 0; - // Phase 2: parallel running balance in seconds for flag-on chain. - int sumFlexEndInSeconds = 0; - //double SumFlexStart = 0; - foreach (var timePlanningWorkingHoursModel in timePlannings) + // Phase 2: parallel running balance in seconds for one-minute rows. + var sumFlexEndInSeconds = 0; + foreach (var row in timePlannings) { + var isOneMinuteRow = row.RegisteredUnderOneMinuteIntervals + ?? timeline.WasOneMinuteAt(row.Date); if (j == 0) { - if (useOneMinuteIntervals) + if (isOneMinuteRow) { - // Phase 2: chain in seconds; back-derive doubles via /3600.0. - timePlanningWorkingHoursModel.SumFlexStartInSeconds = - timePlanningWorkingHoursModel.SumFlexStartInSeconds; - timePlanningWorkingHoursModel.SumFlexStart = - timePlanningWorkingHoursModel.SumFlexStartInSeconds / 3600.0; - timePlanningWorkingHoursModel.SumFlexEndInSeconds = - timePlanningWorkingHoursModel.SumFlexStartInSeconds - + timePlanningWorkingHoursModel.FlexInSeconds - - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel); - timePlanningWorkingHoursModel.SumFlexEnd = - timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0; - sumFlexEndInSeconds = timePlanningWorkingHoursModel.SumFlexEndInSeconds; - sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd; + // The anchor's opening balance falls back to the decimal + // SumFlexStart when the seconds column is still 0 — otherwise + // the whole carried-forward balance is dropped at the head of + // the chain. See PlanRegistrationHelper.SecondsOrDecimalFallback. + row.SumFlexStartInSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback( + row.SumFlexStartInSeconds, row.SumFlexStart); + row.SumFlexStart = row.SumFlexStartInSeconds / 3600.0; + row.SumFlexEndInSeconds = row.SumFlexStartInSeconds + + row.FlexInSeconds + - PaiedOutFlexSecondsWithFallback(row); + row.SumFlexEnd = row.SumFlexEndInSeconds / 3600.0; } else { - timePlanningWorkingHoursModel.SumFlexStart = - Math.Round(timePlanningWorkingHoursModel.SumFlexStart, 2); - timePlanningWorkingHoursModel.SumFlexEnd = Math.Round( - timePlanningWorkingHoursModel.SumFlexStart + timePlanningWorkingHoursModel.FlexHours - - (string.IsNullOrEmpty(timePlanningWorkingHoursModel.PaidOutFlex) - ? 0 - : double.Parse(timePlanningWorkingHoursModel.PaidOutFlex.Replace(",", "."), - CultureInfo.InvariantCulture)), 2); - sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd; + row.SumFlexStart = Math.Round(row.SumFlexStart, 2); + row.SumFlexEnd = Math.Round( + row.SumFlexStart + row.FlexHours - PaidOutFlexHours(row), 2); } } else { - if (useOneMinuteIntervals) + if (isOneMinuteRow) { - timePlanningWorkingHoursModel.SumFlexStartInSeconds = sumFlexEndInSeconds; - timePlanningWorkingHoursModel.SumFlexStart = sumFlexEndInSeconds / 3600.0; + row.SumFlexStartInSeconds = sumFlexEndInSeconds; + row.SumFlexStart = sumFlexEndInSeconds / 3600.0; try { - timePlanningWorkingHoursModel.SumFlexEndInSeconds = - timePlanningWorkingHoursModel.SumFlexStartInSeconds - + timePlanningWorkingHoursModel.FlexInSeconds - - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel); - timePlanningWorkingHoursModel.SumFlexEnd = - timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0; + row.SumFlexEndInSeconds = row.SumFlexStartInSeconds + + row.FlexInSeconds + - PaiedOutFlexSecondsWithFallback(row); + row.SumFlexEnd = row.SumFlexEndInSeconds / 3600.0; } catch (Exception e) { @@ -1052,21 +1072,14 @@ private void ApplyRunningFlexChain( logger.LogError(e.Message); logger.LogTrace(e.StackTrace); } - - sumFlexEndInSeconds = timePlanningWorkingHoursModel.SumFlexEndInSeconds; - sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd; } else { - timePlanningWorkingHoursModel.SumFlexStart = sumFlexEnd; + row.SumFlexStart = sumFlexEnd; try { - timePlanningWorkingHoursModel.SumFlexEnd = Math.Round( - timePlanningWorkingHoursModel.SumFlexStart + timePlanningWorkingHoursModel.FlexHours - - (string.IsNullOrEmpty(timePlanningWorkingHoursModel.PaidOutFlex) - ? 0 - : double.Parse(timePlanningWorkingHoursModel.PaidOutFlex.Replace(",", "."), - CultureInfo.InvariantCulture)), 2); + row.SumFlexEnd = Math.Round( + row.SumFlexStart + row.FlexHours - PaidOutFlexHours(row), 2); } catch (Exception e) { @@ -1074,11 +1087,15 @@ private void ApplyRunningFlexChain( logger.LogError(e.Message); logger.LogTrace(e.StackTrace); } - - sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd; } } + // One tail for all four branches: both accumulators advance together + // so the next row can chain off this one whichever mode it is in. + sumFlexEnd = row.SumFlexEnd; + sumFlexEndInSeconds = isOneMinuteRow + ? row.SumFlexEndInSeconds + : (int)Math.Round(sumFlexEnd * 3600); j++; } } @@ -1628,9 +1645,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else { @@ -1931,9 +1946,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else { @@ -2293,9 +2306,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else { @@ -2585,9 +2596,7 @@ await dbContext.PlanRegistrations.AsNoTracking() if (assignedSite != null && assignedSite.UseOneMinuteIntervals) { PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision( - planRegistration, - preTimePlanning?.SumFlexEndInSeconds ?? 0, - preTimePlanning != null); + planRegistration, preTimePlanning); } else { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index 77701847..343d7188 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj @@ -33,7 +33,7 @@ - +