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