diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs
index 7708b9c4..3418f40b 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -139,6 +140,219 @@ await SeedSiteAndPlanRegistration(
Assert.That(shift1Stop, Is.EqualTo("10:00"), "Legacy slot 121 → Options[120] = \"10:00\"");
}
+ ///
+ /// Regression coverage for the read-time flex chain (ApplyRunningFlexChain
+ /// in TimePlanningWorkingHoursService) failing to deduct a paid-out flex
+ /// on UseOneMinuteIntervals=true sites. Production writers (e.g. the
+ /// "set flex" flow, TimePlanningFlexService.UpdatePlanning) only ever
+ /// populate the legacy double PaiedOutFlex; PaiedOutFlexInSeconds
+ /// stays at its unpopulated default of 0 unless the caller sets it explicitly.
+ /// Pre-fix, the chain subtracted PaiedOutFlexInSeconds with no fallback,
+ /// so 2h flex with 30min paid out rendered as 2h (undeducted) instead of 1.5h.
+ ///
+ [Test]
+ public async Task Index_FlagOn_PaiedOutFlexInSecondsZero_FallsBackToPaidOutFlexDouble()
+ {
+ await SeedFlexScenario(
+ siteUid: 9703,
+ date: new DateTime(2026, 5, 17),
+ flexInSecondsOnTargetDay: 7200, // 2h flex
+ paiedOutFlexOnTargetDay: 0.5, // 30 min paid out — legacy double only
+ paiedOutFlexInSecondsOnTargetDay: 0);
+
+ var result = await _service.Index(new TimePlanningWorkingHoursRequestModel
+ {
+ SiteId = 9703,
+ DateFrom = new DateTime(2026, 5, 16),
+ DateTo = new DateTime(2026, 5, 17),
+ });
+
+ Assert.That(result.Success, Is.True, result.Message);
+ var targetRow = result.Model!.Single(r => r.Date == new DateTime(2026, 5, 17));
+
+ // 2h flex - 30min paid out = 1.5h (5400s). Pre-fix this read 2h (7200s)
+ // because PaiedOutFlexInSeconds (always 0 from production writers) was
+ // subtracted directly with no fallback to the populated double.
+ Assert.That(targetRow.SumFlexEndInSeconds, Is.EqualTo(5400));
+ Assert.That(targetRow.SumFlexEnd, Is.EqualTo(1.5).Within(0.001));
+ }
+
+ ///
+ /// When PaiedOutFlexInSeconds IS populated (e.g. a future writer that
+ /// keeps it in sync), that value must win over the double-derived fallback —
+ /// mirrors PlanRegistrationHelperTests.SumFlex_FlagOn_PaiedOutFlexInSecondsZero_FallsBackToPaiedOutFlex's
+ /// counterpart case for the write-time chain.
+ ///
+ [Test]
+ public async Task Index_FlagOn_PaiedOutFlexInSecondsSet_UsesStoredSecondsOverDouble()
+ {
+ await SeedFlexScenario(
+ siteUid: 9704,
+ date: new DateTime(2026, 5, 18),
+ flexInSecondsOnTargetDay: 7200, // 2h flex
+ paiedOutFlexOnTargetDay: 0.5, // legacy double says 30 min...
+ paiedOutFlexInSecondsOnTargetDay: 900); // ...but the seconds column says 15 min; that must win.
+
+ var result = await _service.Index(new TimePlanningWorkingHoursRequestModel
+ {
+ SiteId = 9704,
+ DateFrom = new DateTime(2026, 5, 17),
+ DateTo = new DateTime(2026, 5, 18),
+ });
+
+ Assert.That(result.Success, Is.True, result.Message);
+ var targetRow = result.Model!.Single(r => r.Date == new DateTime(2026, 5, 18));
+
+ // 2h flex - 15min (the populated *InSeconds* column) = 1h45m (6300s).
+ Assert.That(targetRow.SumFlexEndInSeconds, Is.EqualTo(6300));
+ Assert.That(targetRow.SumFlexEnd, Is.EqualTo(1.75).Within(0.001));
+ }
+
+ [Test]
+ public async Task GenerateExcelDashboard_FlagOn_PaiedOutFlexSet_SumFlexEndCellDeductsIt()
+ {
+ await SeedFlexScenario(
+ siteUid: 9705,
+ date: new DateTime(2026, 5, 19),
+ flexInSecondsOnTargetDay: 7200, // 2h flex
+ paiedOutFlexOnTargetDay: 0.5, // 30 min paid out — legacy double only
+ paiedOutFlexInSecondsOnTargetDay: 0);
+
+ var result = await _service.GenerateExcelDashboard(new TimePlanningWorkingHoursRequestModel
+ {
+ SiteId = 9705,
+ DateFrom = new DateTime(2026, 5, 19),
+ DateTo = new DateTime(2026, 5, 19),
+ });
+
+ Assert.That(result.Success, Is.True, result.Message);
+ Assert.That(result.Model, Is.Not.Null);
+
+ var (sumFlexEnd, paidOutFlex) = ReadFlexCells(result.Model!);
+ Assert.That(double.Parse(sumFlexEnd, CultureInfo.InvariantCulture), Is.EqualTo(1.5).Within(0.001),
+ "SumFlexEnd column must reflect Flex minus PaiedOutFlex, not the raw Flex total " +
+ "(regression lock for the read-time chain fallback fix)");
+ Assert.That(double.Parse(paidOutFlex, CultureInfo.InvariantCulture), Is.EqualTo(0.5).Within(0.001));
+ }
+
+ ///
+ /// Seeds an SDK Site/Worker + UseOneMinuteIntervals=true AssignedSite + a
+ /// neutral prior-day PlanRegistration (so the running SumFlexStart chain
+ /// enters the target day at exactly 0) + a target-day PlanRegistration
+ /// carrying the exact production shape of the PaiedOutFlex bug: FlexInSeconds
+ /// set, the legacy double PaiedOutFlex set, and PaiedOutFlexInSeconds left at
+ /// whatever the caller passes (0 = the value every production writer leaves it at).
+ ///
+ private async Task SeedFlexScenario(
+ int siteUid, DateTime date,
+ int flexInSecondsOnTargetDay, double paiedOutFlexOnTargetDay, int paiedOutFlexInSecondsOnTargetDay)
+ {
+ var core = await GetCore();
+ var sdkDb = core.DbContextHelper.GetDbContext();
+
+ var site = new SdkSite { Name = $"Site {siteUid}", MicrotingUid = siteUid };
+ await site.Create(sdkDb);
+
+ var worker = new SdkWorker
+ {
+ FirstName = "Test",
+ LastName = "Worker",
+ Email = $"test{siteUid}@example.com",
+ MicrotingUid = 1000 + siteUid,
+ };
+ await worker.Create(sdkDb);
+
+ var siteWorker = new SdkSiteWorker
+ {
+ SiteId = site.Id,
+ WorkerId = worker.Id,
+ MicrotingUid = 2000 + siteUid,
+ };
+ await siteWorker.Create(sdkDb);
+
+ await new AssignedSiteEntity
+ {
+ SiteId = siteUid,
+ UseOneMinuteIntervals = true,
+ WorkflowState = Constants.WorkflowStates.Created,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1,
+ }.Create(TimePlanningPnDbContext!);
+
+ await new PlanRegistrationEntity
+ {
+ SdkSitId = siteUid,
+ Date = date.AddDays(-1),
+ Start1Id = 0,
+ Stop1Id = 0,
+ Pause1Id = 0,
+ PlanText = "",
+ CommentOffice = "",
+ CommentOfficeAll = "",
+ WorkflowState = Constants.WorkflowStates.Created,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1,
+ }.Create(TimePlanningPnDbContext!);
+
+ await new PlanRegistrationEntity
+ {
+ SdkSitId = siteUid,
+ Date = date,
+ Start1Id = 0,
+ Stop1Id = 0,
+ Pause1Id = 0,
+ FlexInSeconds = flexInSecondsOnTargetDay,
+ PaiedOutFlex = paiedOutFlexOnTargetDay,
+ PaiedOutFlexInSeconds = paiedOutFlexInSecondsOnTargetDay,
+ PlanText = "",
+ CommentOffice = "",
+ CommentOfficeAll = "",
+ WorkflowState = Constants.WorkflowStates.Created,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1,
+ }.Create(TimePlanningPnDbContext!);
+ }
+
+ ///
+ /// Reads the (SumFlexEnd, PaidOutFlex) cell text from the first data row that
+ /// has either populated. Column layout from FillDataRow when
+ /// Third/Fourth/FifthShiftActive are all off (0-indexed): ...14=NettoHours,
+ /// 15=FlexHours, 16=SumFlexEnd, 17=PaidOutFlex(numeric), 18=Message, ...
+ ///
+ private static (string SumFlexEnd, string PaidOutFlex) ReadFlexCells(Stream xlsx)
+ {
+ xlsx.Position = 0;
+ using var doc = SpreadsheetDocument.Open(xlsx, false);
+ var workbookPart = doc.WorkbookPart!;
+ var dashboardSheet = workbookPart.Workbook.Descendants()
+ .First(s => s.Name == "Dashboard");
+ var dashboardPart = (WorksheetPart)workbookPart.GetPartById(dashboardSheet.Id!);
+ var sheet = dashboardPart.Worksheet;
+ var sst = workbookPart.SharedStringTablePart?.SharedStringTable;
+ string CellText(Cell c)
+ {
+ var raw = c.CellValue?.Text ?? c.InnerText ?? "";
+ if (c.DataType?.Value == CellValues.SharedString && sst != null && int.TryParse(raw, out var idx))
+ {
+ return sst.ElementAt(idx).InnerText;
+ }
+ return raw;
+ }
+ var rows = sheet.Descendants().ToList();
+ foreach (var row in rows.Where(r => r.RowIndex == null || r.RowIndex! > 1U))
+ {
+ var cells = row.Elements| ().ToList();
+ if (cells.Count < 18) continue;
+ var sumFlexEnd = CellText(cells[16]);
+ var paidOutFlex = CellText(cells[17]);
+ if (!string.IsNullOrEmpty(sumFlexEnd) || !string.IsNullOrEmpty(paidOutFlex))
+ {
+ return (sumFlexEnd, paidOutFlex);
+ }
+ }
+ return ("", "");
+ }
+
private async Task SeedSiteAndPlanRegistration(
int siteUid, DateTime date, bool useOneMinuteIntervals,
DateTime? start1, DateTime? stop1,
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 66ee2507..dfdcb8dd 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
@@ -247,8 +247,22 @@ private async Task UpdatePlanning(PlanRegistration planRegistration,
{
planRegistration.CommentOfficeAll = model.CommentOfficeAll;
planRegistration.CommentOffice = model.CommentOffice;
+
+ // 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 newPaiedOutFlexSeconds = (int)Math.Round(model.PaidOutFlex * 3600);
+
planRegistration.SumFlexEnd += planRegistration.PaiedOutFlex - model.PaidOutFlex;
+ planRegistration.SumFlexEndInSeconds += oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds;
planRegistration.PaiedOutFlex = model.PaidOutFlex;
+ planRegistration.PaiedOutFlexInSeconds = newPaiedOutFlexSeconds;
planRegistration.UpdatedByUserId = userService.UserId;
await planRegistration.Update(dbContext);
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 b666020d..ed82fbd4 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
@@ -958,6 +958,26 @@ public async Task> CalculateH
}
+ ///
+ /// Production writers (e.g. TimePlanningFlexService.UpdatePlanning) only
+ /// ever populate the legacy double PaiedOutFlex/PaidOutFlex fields;
+ /// PaiedOutFlexInSeconds stays at its default of 0. Mirrors the identical
+ /// fallback in
+ /// so the flag-on chain doesn't silently treat a real paid-out flex as zero.
+ ///
+ private static int PaiedOutFlexSecondsWithFallback(TimePlanningWorkingHoursModel model)
+ {
+ if (model.PaiedOutFlexInSeconds != 0)
+ {
+ return model.PaiedOutFlexInSeconds;
+ }
+
+ return string.IsNullOrEmpty(model.PaidOutFlex)
+ ? 0
+ : (int)Math.Round(
+ double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture) * 3600);
+ }
+
///
/// 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
@@ -992,7 +1012,7 @@ private void ApplyRunningFlexChain(
timePlanningWorkingHoursModel.SumFlexEndInSeconds =
timePlanningWorkingHoursModel.SumFlexStartInSeconds
+ timePlanningWorkingHoursModel.FlexInSeconds
- - timePlanningWorkingHoursModel.PaiedOutFlexInSeconds;
+ - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel);
timePlanningWorkingHoursModel.SumFlexEnd =
timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0;
sumFlexEndInSeconds = timePlanningWorkingHoursModel.SumFlexEndInSeconds;
@@ -1022,7 +1042,7 @@ private void ApplyRunningFlexChain(
timePlanningWorkingHoursModel.SumFlexEndInSeconds =
timePlanningWorkingHoursModel.SumFlexStartInSeconds
+ timePlanningWorkingHoursModel.FlexInSeconds
- - timePlanningWorkingHoursModel.PaiedOutFlexInSeconds;
+ - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel);
timePlanningWorkingHoursModel.SumFlexEnd =
timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0;
}
|