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; }