Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -139,6 +140,219 @@ await SeedSiteAndPlanRegistration(
Assert.That(shift1Stop, Is.EqualTo("10:00"), "Legacy slot 121 → Options[120] = \"10:00\"");
}

/// <summary>
/// Regression coverage for the read-time flex chain (<c>ApplyRunningFlexChain</c>
/// in <c>TimePlanningWorkingHoursService</c>) failing to deduct a paid-out flex
/// on <c>UseOneMinuteIntervals=true</c> sites. Production writers (e.g. the
/// "set flex" flow, <c>TimePlanningFlexService.UpdatePlanning</c>) only ever
/// populate the legacy double <c>PaiedOutFlex</c>; <c>PaiedOutFlexInSeconds</c>
/// stays at its unpopulated default of 0 unless the caller sets it explicitly.
/// Pre-fix, the chain subtracted <c>PaiedOutFlexInSeconds</c> with no fallback,
/// so 2h flex with 30min paid out rendered as 2h (undeducted) instead of 1.5h.
/// </summary>
[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));
}

/// <summary>
/// When <c>PaiedOutFlexInSeconds</c> IS populated (e.g. a future writer that
/// keeps it in sync), that value must win over the double-derived fallback —
/// mirrors <c>PlanRegistrationHelperTests.SumFlex_FlagOn_PaiedOutFlexInSecondsZero_FallsBackToPaiedOutFlex</c>'s
/// counterpart case for the write-time chain.
/// </summary>
[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));
}

/// <summary>
/// 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).
/// </summary>
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!);
}

/// <summary>
/// Reads the (SumFlexEnd, PaidOutFlex) cell text from the first data row that
/// has either populated. Column layout from <c>FillDataRow</c> when
/// Third/Fourth/FifthShiftActive are all off (0-indexed): ...14=NettoHours,
/// 15=FlexHours, 16=SumFlexEnd, 17=PaidOutFlex(numeric), 18=Message, ...
/// </summary>
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<Sheet>()
.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<Row>().ToList();
foreach (var row in rows.Where(r => r.RowIndex == null || r.RowIndex! > 1U))
{
var cells = row.Elements<Cell>().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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +251 to +265
planRegistration.UpdatedByUserId = userService.UserId;

await planRegistration.Update(dbContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,26 @@ public async Task<OperationDataResult<TimePlanningHoursSummaryModel>> CalculateH
}


/// <summary>
/// Production writers (e.g. <c>TimePlanningFlexService.UpdatePlanning</c>) only
/// ever populate the legacy double <c>PaiedOutFlex</c>/<c>PaidOutFlex</c> fields;
/// <c>PaiedOutFlexInSeconds</c> stays at its default of 0. Mirrors the identical
/// fallback in <see cref="PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision"/>
/// so the flag-on chain doesn't silently treat a real paid-out flex as zero.
/// </summary>
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);
Comment on lines +975 to +978
}

/// <summary>
/// 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1022,7 +1042,7 @@ private void ApplyRunningFlexChain(
timePlanningWorkingHoursModel.SumFlexEndInSeconds =
timePlanningWorkingHoursModel.SumFlexStartInSeconds
+ timePlanningWorkingHoursModel.FlexInSeconds
- timePlanningWorkingHoursModel.PaiedOutFlexInSeconds;
- PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel);
timePlanningWorkingHoursModel.SumFlexEnd =
timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0;
}
Expand Down
Loading