diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index 0bba518b..3f0f8322 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -877,4 +877,121 @@ public void GenerateExcelDashboard_FlagOn_FormatsShiftCellsAsHHmmss() // Assert.That(pkg2.Workbook.Worksheets.First().Cells[2, 9].Text, Is.EqualTo("15:30")); Assert.Pass("Captured for future fixture work; see XML doc above."); } + + /// + /// Customer 855 regression: with UseOneMinuteIntervals = true, a 3-minute + /// pause stamped via Pause1StartedAt/Pause1StoppedAt must aggregate to 3 — + /// not be rounded to 0 by the legacy (Pause1Id * 5) - 5 formula. The + /// companion Pause1Id = 1 is intentionally lossy (storage formula + /// (3/5)+1 = 1) and must be ignored when the flag is on. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_RoundTrips3MinPause() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddHours(12), + Pause1StoppedAt = someDate.AddHours(12).AddMinutes(3), + Pause1Id = 1, // lossy legacy companion (3/5)+1 = 1; must be IGNORED when flag is on + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(3)); + } + + /// + /// Documents the legacy lossy behavior: with UseOneMinuteIntervals = false, + /// a 3-minute pause stored as Pause1Id = 1 reads back as 0 minutes via the + /// 5-minute-tick formula. Catches accidental flag-off regressions where a + /// row that should be precise (flag on) silently reverts to legacy math. + /// + [Test] + public void AggregatePauseMinutes_LegacyMode_3MinPauseReturns0_DocumentsLossy() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddHours(12), + Pause1StoppedAt = someDate.AddHours(12).AddMinutes(3), + Pause1Id = 1, // legacy companion of a 3-min pause: (3/5)+1 = 1 → 0 min via legacy formula + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + /// + /// Verifies that AggregatePauseMinutes sums precise pauses across multiple + /// slots when UseOneMinuteIntervals is on (3 + 7 = 10 minutes). + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_SumsAcrossMultipleSlots() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddHours(10), + Pause1StoppedAt = someDate.AddHours(10).AddMinutes(3), + Pause2StartedAt = someDate.AddHours(14), + Pause2StoppedAt = someDate.AddHours(14).AddMinutes(7), + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(10)); + } + + /// + /// Sanity check that the legacy 5-minute-tick path still returns the + /// canonical value: Pause1Id = 4 → (4 * 5) - 5 = 15 minutes. + /// + [Test] + public void AggregatePauseMinutes_LegacyMode_15MinPauseReturns15() + { + // Arrange + var pr = new PlanRegistration + { + Pause1Id = 4, // legacy 15-minute pause: (4 * 5) - 5 = 15 + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + + // Assert + Assert.That(result, Is.EqualTo(15)); + } + + /// + /// When UseOneMinuteIntervals is on, the legacy Pause*Id field must be + /// ignored: a row with Pause1Id = 4 (legacy 15 min) but no DateTime stamps + /// is treated as zero pause. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_NullStampsContributeZero() + { + // Arrange + var pr = new PlanRegistration + { + Pause1Id = 4, // legacy 15-min pause; flag-on path must ignore this + // Pause1StartedAt / Pause1StoppedAt left null + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } } \ No newline at end of file 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 61ffb337..f6956cd3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -447,6 +447,50 @@ long ShiftSeconds(DateTime? startAt, DateTime? stopAt, int startId, int stopId, return Math.Max(0, nettoSeconds); } + /// + /// Aggregates pause minutes for a PlanRegistration. + /// + /// When useOneMinuteIntervals is true, sums the (Pause*StoppedAt - Pause*StartedAt) + /// DateTime deltas in seconds across all 5 pause slots that have BOTH stamps populated, + /// then rounds to whole minutes. This preserves second/minute precision for pauses + /// recorded by workers on UseOneMinuteIntervals-enabled sites (e.g. a 3-min pause + /// reads as 3, not 0). + /// + /// When useOneMinuteIntervals is false, falls back to the legacy 5-minute-tick + /// formula: for each Pause*Id > 0, contribute (Pause*Id * 5) - 5 minutes. (Pause*Id + /// stores break in 5-minute ticks plus a +1 sentinel: Pause1Id = 1 means 0 min, + /// Pause1Id = 4 means 15 min, etc.) + /// + public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIntervals) + { + if (useOneMinuteIntervals) + { + long totalSeconds = 0; + totalSeconds += PauseSpanSeconds(pr.Pause1StartedAt, pr.Pause1StoppedAt); + totalSeconds += PauseSpanSeconds(pr.Pause2StartedAt, pr.Pause2StoppedAt); + totalSeconds += PauseSpanSeconds(pr.Pause3StartedAt, pr.Pause3StoppedAt); + totalSeconds += PauseSpanSeconds(pr.Pause4StartedAt, pr.Pause4StoppedAt); + totalSeconds += PauseSpanSeconds(pr.Pause5StartedAt, pr.Pause5StoppedAt); + return (int)(totalSeconds / 60); // round down to whole minutes + } + + // Legacy 5-minute-tick path + var totalMinutes = 0; + if (pr.Pause1Id > 0) totalMinutes += (pr.Pause1Id * 5) - 5; + if (pr.Pause2Id > 0) totalMinutes += (pr.Pause2Id * 5) - 5; + if (pr.Pause3Id > 0) totalMinutes += (pr.Pause3Id * 5) - 5; + if (pr.Pause4Id > 0) totalMinutes += (pr.Pause4Id * 5) - 5; + if (pr.Pause5Id > 0) totalMinutes += (pr.Pause5Id * 5) - 5; + return totalMinutes; + } + + private static long PauseSpanSeconds(DateTime? startedAt, DateTime? stoppedAt) + { + if (startedAt == null || stoppedAt == null) return 0; + var span = stoppedAt.Value - startedAt.Value; + return span.TotalSeconds > 0 ? (long)span.TotalSeconds : 0; + } + /// /// Phase 2 — write the second-precision NettoHours / Flex / SumFlex chain. /// @@ -1547,21 +1591,7 @@ await dbContext.PlanRegistrations.AsNoTracking() Pause5StoppedAt = planRegistration.Pause5StoppedAt }; - planningModel.PauseMinutes += planRegistration.Pause1Id > 0 - ? (planRegistration.Pause1Id * 5) - 5 - : 0; - planningModel.PauseMinutes += planRegistration.Pause2Id > 0 - ? (planRegistration.Pause2Id * 5) - 5 - : 0; - planningModel.PauseMinutes += planRegistration.Pause3Id > 0 - ? (planRegistration.Pause3Id * 5) - 5 - : 0; - planningModel.PauseMinutes += planRegistration.Pause4Id > 0 - ? (planRegistration.Pause4Id * 5) - 5 - : 0; - planningModel.PauseMinutes += planRegistration.Pause5Id > 0 - ? (planRegistration.Pause5Id * 5) - 5 - : 0; + planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, dbAssignedSite.UseOneMinuteIntervals); // planningModel.PauseMinutes = planningModel.PauseMinutes > 0 ? planningModel.PauseMinutes - 5 : 0;