From dc20cd16122126d686bd57d403478cc5fa12bc6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 15:51:55 +0200 Subject: [PATCH] fix(lock): use UTC for the freeze, and stop three fail-open paths and two silent skips Post-merge audit findings on the "Afstemt" reconciled-day lock. 1. The freeze compares against UTC. DayLockHelper.CanReconcile used DateTime.Now. PlanRegistration.Date is a calendar-day label with the time zeroed, not an instant in any timezone, so there was no local midnight for a local clock to be consistent with. Now DateTime.UtcNow. CanReconcile is plugin-only, so no twin drift with the service repo. 2. Three fail-open null branches now refuse or skip. A rule whose whole purpose is "this day cannot be written" must not waive itself when the site id is unknown. - RefuseIfNotWritableAsync (renamed from CheckDayLockAsync, since it now refuses on two grounds and only one is a day lock): a null site id returned null, which is this method's "proceed" answer. It now refuses. The kiosk overload has no null guard of its own and dereferences the same id with `!` further down, so a null used to end as a raw InvalidOperationException. - Import: a sheet whose site has no MicrotingUid produced a null boundary, which makes IsLocked false for EVERY row, importing the whole sheet with no lock check. It is now skipped, counted and reported. - GoogleSheetHelper: the lock line's null check was provably dead, and had it fired it would have processed the row with no lock check while three neighbouring lines throw. It now matches its neighbours. 3. Swallowed exception detail restored, at both sites in PlanRegistrationHelper. The try blocks contain planRegistration.Update calls, so DbUpdateException, DbUpdateConcurrencyException and MySqlException were all reported as the string "Could not parse PlanText" while the type and stack went to Trace. Both sites now capture the exception itself, once per channel. 4. The xlsx import no longer reports plain success while dropping rows. Locked days and unresolvable sheets are counted and named in the result, the way ReconcileThrough already distinguishes its skip reasons. The counts are also logged server-side in a finally, so they survive the exception path, and an unresolvable sheet raises a Sentry warning. 5. UpdateAssignedSite needs no guard, and now says so. It only touches Date >= today (UTC midnight), while a locked day is always strictly in the past: Reconciled has one writer, and the two callers that set it true both gate on CanReconcile. The ranges are disjoint. Comment only. Also in this change, beyond the five fixes: - PlanRegistrationHelper's second Sentry site changed from `catch (Exception)` to `catch (Exception e) when (e is not DayLockedException)`. That is a CONTROL-FLOW change, not just a logging one. The net outcome is unchanged: the rejected entry stays tracked either way, so the sole caller's own Update at TimePlanningWorkingHoursService.cs:720 rethrows whether or not this catch swallowed it. Without the filter, uncommenting the capture would have started reporting routine lock refusals to Sentry as incidents. - Adding a SiteNotFound entry to Translations.resx and Translations.da.resx changes user-visible text at SEVEN pre-existing call sites, not just the new one: TimePlanningWorkingHoursService.cs:1231, 1440 and TimePlanningPlanningService.cs:507, 519, 530, 1209, 1221. The key was already in use but had no resource entry, so those flows rendered the raw string "SiteNotFound". They will now say "Medarbejder ikke fundet.", including in flows unrelated to the lock. - The import's failure path used to show a GREEN success toast for a server-reported failure. The endpoint returns OperationResult with HTTP 200 regardless of its Success flag, so ng2-file-upload routes failures to onSuccessItem. The upload modal now branches on the body rather than the transport, and shows the server's own diagnosis as an error. Co-Authored-By: Claude Opus 5 --- .../DayLockHelperTests.cs | 19 +- .../ReconcileServiceTests.cs | 54 ++- .../WorkingHoursImportRemovedRowTests.cs | 60 +++ .../Infrastructure/Helpers/DayLockHelper.cs | 25 +- .../Helpers/GoogleSheetHelper.cs | 10 +- .../Helpers/PlanRegistrationHelper.cs | 40 +- .../Resources/Translations.Designer.cs | 24 ++ .../Resources/Translations.da.resx | 12 + .../Resources/Translations.resx | 12 + .../TimePlanningPlanningService.cs | 16 +- .../TimeSettingService.cs | 406 +++++++++--------- .../TimePlanningWorkingHoursService.cs | 138 +++++- .../working-hours-upload-modal.component.ts | 63 ++- 13 files changed, 630 insertions(+), 249 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index 4159fcbdc..ae47b6afc 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -169,16 +169,27 @@ public void IsLocked_IgnoresTimeOfDay() Is.True, "the boundary day is locked for its whole length"); } + /// + /// UtcNow, not Now, on BOTH sides, because CanReconcile reads UtcNow. The + /// shipped container has no TZ set so the two agree there; on a dev machine + /// they diverge, and then DateTime.Now here breaks in either direction: + /// - UTC+N, just after local midnight (Now.Date is a day AHEAD of + /// UtcNow.Date): the "yesterday is reconcilable" assertion fails, because + /// Now.Date.AddDays(-1) IS UtcNow.Date, i.e. still today in UTC. + /// - UTC-N, late in the local evening (Now.Date is a day BEHIND): the + /// "today is not reconcilable" assertion fails instead, because Now.Date + /// is already yesterday in UTC and so genuinely reconcilable. + /// [Test] public void CanReconcile_TodayAndFuture_False_Past_True() { Assert.Multiple(() => { - Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date), Is.False, + Assert.That(DayLockHelper.CanReconcile(DateTime.UtcNow.Date), Is.False, "today must stay open so time can still be registered"); - Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(1)), Is.False); - Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(-1)), Is.True); - Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddHours(23)), Is.False, + Assert.That(DayLockHelper.CanReconcile(DateTime.UtcNow.Date.AddDays(1)), Is.False); + Assert.That(DayLockHelper.CanReconcile(DateTime.UtcNow.Date.AddDays(-1)), Is.True); + Assert.That(DayLockHelper.CanReconcile(DateTime.UtcNow.Date.AddHours(23)), Is.False, "a time-of-day on today is still today"); }); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index 58e5d2ec9..161c541e5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -384,10 +384,28 @@ public async Task Reconcile_APastDay_SetsFlagAndTimestamp() }); } + /// + /// UtcNow, deliberately: this date is fed to CanReconcile, which compares + /// against UtcNow.Date, and it sits exactly ON that boundary -- so a local + /// clock an offset away from UTC flips the outcome. + /// + /// The fixture's other DateTime.Now dates stay as they are, including the + /// many that DO reach CanReconcile via _service.Reconcile. Every date that + /// reaches it is -3 or older, or +3 forward, and the largest real UTC offset + /// is 14 hours, so no offset can move one across the boundary. The file's -1 + /// dates never reach CanReconcile: they are open days and window bounds, + /// tested by IsLocked against a boundary the fixture stored on the same + /// clock. Only a date sitting exactly ON the boundary can be flipped, and + /// this test holds the only one. + /// + /// Reconcile_AFutureDay_IsRejected below moved too, even though +3 days is + /// safe at any offset, so the pair of tests naming this one rule reads on + /// one clock rather than two. + /// [Test] public async Task Reconcile_Today_IsRejected() { - var row = await SeedPlain(901, DateTime.Now.Date); + var row = await SeedPlain(901, DateTime.UtcNow.Date); var result = await _service.Reconcile(row.Id); @@ -399,7 +417,7 @@ public async Task Reconcile_Today_IsRejected() [Test] public async Task Reconcile_AFutureDay_IsRejected() { - var row = await SeedPlain(902, DateTime.Now.Date.AddDays(3)); + var row = await SeedPlain(902, DateTime.UtcNow.Date.AddDays(3)); var result = await _service.Reconcile(row.Id); @@ -876,6 +894,38 @@ public async Task UpdateWorkingHour_Kiosk_AnOlderReconciledDay_ReturnsDayIsRecon Assert.That(result.Message, Is.EqualTo("DayIsReconciled")); } + /// + /// The lock's unknown case must REFUSE, not permit. The kiosk overload takes + /// an int? site id with no null guard of its own, so before the fix a null + /// sailed straight past the guard (RefuseIfNotWritableAsync's null answer + /// means "writable, proceed") and then died on a `sdkSiteId!.Value` + /// dereference further down. Now it answers with a message and writes + /// nothing. + /// + /// Note what the null case is NOT: with no site id the lock is never + /// evaluated at all, so relaxing the refusal would not "let a locked day + /// through" -- it would crash on that dereference instead, which is how the + /// bug presented. The seeded boundary is here to make the scenario realistic + /// (a real kiosk posting into a frozen period), not because the assertion + /// depends on the day being locked. + /// + [Test] + public async Task UpdateWorkingHour_Kiosk_WithNoSiteId_IsRefused_AndWritesNothing() + { + var token = await SeedKioskDeviceAsync(); + await SeedReconciledBoundaryAsync(934, DateTime.Now.Date.AddDays(-3)); + var lockedDate = DateTime.Now.Date.AddDays(-4); + + var result = await BuildWorkingHoursService().UpdateWorkingHour( + null, new TimePlanningWorkingHoursUpdateModel { Date = lockedDate }, token); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("SiteNotFound")); + Assert.That(await TimePlanningPnDbContext!.PlanRegistrations + .AnyAsync(x => x.Date == lockedDate), Is.False, + "an unresolvable site must not create a row on a locked day"); + } + [Test] public async Task UpdateWorkingHour_Personal_TheBoundaryDay_ReturnsDayIsReconciled() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs index 9e8a483b8..5a633bb4e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs @@ -48,6 +48,11 @@ public async Task SetUpTest() var localizationService = Substitute.For(); localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + // The format overload echoes its arguments, so a test can assert what a + // message NAMES (here: how many locked days the import left alone), not + // just which key it used. + localizationService.GetString(Arg.Any(), Arg.Any()) + .Returns(x => x[0] + "|" + string.Join("|", (object[])x[1])); _coreService = Substitute.For(); var core = await GetCore(); @@ -114,6 +119,8 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() // Post-fix: no crash, and the ACTIVE row is the import target. Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Message, Is.EqualTo("Imported"), + "an import that skipped nothing says only that it imported"); var reloadedActive = await TimePlanningPnDbContext.PlanRegistrations .AsNoTracking().FirstAsync(x => x.Id == activeId); @@ -142,6 +149,11 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() /// day (it drops dates before now minus one day, which by time of day also /// drops yesterday), so no boundary that satisfies I2 is reachable. The /// lock itself does not check I2; this pins the skip as defense in depth. + /// + /// It also pins that the skip is REPORTED. A silent skip is what makes + /// someone re-import a corrected timesheet over a reconciled month forever: + /// they are told "Imported", they see the old numbers, and nothing in the + /// result says which days did not move. /// [Test] public async Task Import_SkipsALockedDay_AndImportsTheRest() @@ -178,6 +190,10 @@ public async Task Import_SkipsALockedDay_AndImportsTheRest() var result = await _service.Import(FormFile(xlsx)); Assert.That(result.Success, Is.True, result.Message); + // The mock echoes "key|arg", so this asserts both the message and the + // count it names -- one locked day, not "some". + Assert.That(result.Message, Is.EqualTo("Imported ImportLockedDaysSkipped|1"), + "the result must say how many days the lock left unchanged"); var lockedAfter = await TimePlanningPnDbContext.PlanRegistrations .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); @@ -192,6 +208,50 @@ public async Task Import_SkipsALockedDay_AndImportsTheRest() Assert.That(openRow.PlanText, Is.EqualTo("IMPORTED-OPEN"), "the open day is still imported"); } + /// + /// A sheet whose name DOES match a worker, but whose worker has no + /// MicrotingUid, is skipped whole AND reported. Skipping it is the lock + /// requirement -- an unreadable boundary makes IsLocked false for every row, + /// so importing it would import the sheet with no lock check at all. But a + /// silent skip here would be its own bug: the name matched, so the user + /// believes that worker's sheet went in, and a plain success would leave + /// them re-importing a file that never lands. + /// + /// Distinct from a sheet matching NO worker, which stays deliberately + /// silent -- such a tab may not be about a worker at all. + /// + [Test] + public async Task Import_ASheetWhoseWorkerHasNoMicrotingUid_IsSkippedAndReported() + { + const string siteName = "ImportNoUidSite"; + var importDate = DateTime.Now.AddDays(5).Date; + + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + // Name matches the worksheet; MicrotingUid deliberately absent. Cleared + // AFTER Create and then re-read, so the arrange cannot quietly test the + // wrong thing if Create ever starts back-filling a uid of its own. + var site = new SdkSite { Name = siteName, MicrotingUid = null }; + await site.Create(sdkDbContext); + site.MicrotingUid = null; + await sdkDbContext.SaveChangesAsync(); + Assert.That((await sdkDbContext.Sites.AsNoTracking().FirstAsync(x => x.Id == site.Id)) + .MicrotingUid, Is.Null, "arrange: the site must really have no MicrotingUid"); + + var xlsx = BuildWorkbook(siteName, + (importDate.ToString("dd.MM.yyyy"), "8", "SHOULD-NOT-LAND")); + + var result = await _service.Import(FormFile(xlsx)); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Message, Is.EqualTo("Imported ImportUnresolvableSheetsSkipped|1"), + "the result must name the sheet it could not import"); + + Assert.That(await TimePlanningPnDbContext.PlanRegistrations + .AnyAsync(x => x.Date == importDate), Is.False, + "a sheet that cannot be lock-checked must not be imported at all"); + } + private static IFormFile FormFile(byte[] xlsx) { var file = Substitute.For(); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index b4cf9f8e3..493d0af05 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -131,10 +131,29 @@ public static async Task LockedMessageKeyAsync( /// registered. This is also what makes the forward flex cascades unable to /// reach a locked day -- see the design doc before relaxing it. /// - /// DateTime.Now, not UtcNow: PlanRegistration.Date is a local midnight, and - /// the existing mobile guard compares the same way. + /// DateTime.UtcNow, not Now. PlanRegistration.Date is a calendar-day LABEL + /// with the time zeroed, not an instant in any timezone, so there is no + /// local midnight for a local clock to be "consistent" with. Writers do not + /// even agree on how they build that label -- most zero a constructed date, + /// but TimePlanningFlexService derives one from DateTime.Now -- which is a + /// reason to pin THIS comparison to one clock, not to follow theirs. + /// + /// Direction, stated as what it is rather than as a law: at a POSITIVE + /// offset (CET/CEST, where this product runs) UtcNow is the conservative + /// choice -- late in the local day it briefly declines to freeze a day that + /// is still "today" in UTC, and for a rule whose whole purpose is "this day + /// can no longer be written", refusing too much beats allowing too much. At + /// a NEGATIVE offset the same expression is the PERMISSIVE one: it would + /// accept the local today and break I2 from the worker's point of view. + /// That case does not arise here, and the answer if it ever does is an + /// explicit business timezone, not a switch back to the server's clock. + /// + /// This repo's Dockerfile sets no TZ and installs no tzdata, and the + /// mcr.microsoft.com/dotnet/aspnet base image it runs on defaults to UTC, so + /// the container this plugin ships in compares the same way before and after + /// this change; it only makes dev machines behave like the container. /// - public static bool CanReconcile(DateTime date) => date.Date < DateTime.Now.Date; + public static bool CanReconcile(DateTime date) => date.Date < DateTime.UtcNow.Date; /// /// What counts as a boundary row, in one place: Reconciled and not diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index 702aef7a3..146c83013 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -355,8 +355,14 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn // Decided before the row is even loaded, so a locked row is // never tracked and no later save in this loop can flush it. - if (site.MicrotingUid is { } lockSiteUid - && DayLockHelper.IsLocked(lockedThroughBySite, lockSiteUid, dateValue)) + // + // `!` deliberately: sitesByKey filters MicrotingUid != null + // (the Where above), and `workers` is built only from + // sitesByKey, so a null cannot reach this line. Written as a + // null-tolerant check it would not be defensive -- it would + // silently skip the lock check for a value that cannot + // occur, which is the one outcome this rule must never have. + if (DayLockHelper.IsLocked(lockedThroughBySite, (int)site.MicrotingUid!, dateValue)) { lockedDaysSkipped++; continue; 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 111462e52..1ad05b941 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -867,13 +867,17 @@ await dbContext.PlanRegistrations.AsNoTracking() // stays tracked and the next save fails on it anyway. catch (Exception e) when (e is not DayLockedException) { - logger.LogError( - $"Could not parse PlanText for planning with id: {planRegistration.Id} the PlanText was: {planRegistration.PlanText}"); - SentrySdk.CaptureMessage( - $"Could not parse PlanText for planning with id: {planRegistration.Id} the PlanText was: {planRegistration.PlanText}"); - //SentrySdk.CaptureException(e); - logger.LogError(e.Message); - logger.LogTrace(e.StackTrace); + // The try above ends in planRegistration.Update calls, so + // this catches DbUpdateException, DbUpdateConcurrencyException, + // MySqlException and friends -- not just PlanText parsing. + // It used to guess "Could not parse PlanText" twice (log + + // Sentry message) while the actual type and stack went to + // Trace, which most hosts do not emit. Report the exception + // ONCE, on each channel, and let it name itself. + SentrySdk.CaptureException(e); + logger.LogError(e, + "UpdatePlanRegistrationsInPeriod failed for PlanRegistration {PlanRegistrationId} (PlanText: {PlanText})", + planRegistration.Id, planRegistration.PlanText); } } @@ -1557,10 +1561,26 @@ await dbContext.PlanRegistrations.AsNoTracking() await planRegistration.Update(dbContext).ConfigureAwait(false); } } - catch (Exception) + // Same shape as the catch in UpdatePlanRegistrationsInPeriod, and + // fixed the same way. The try above ends in a planRegistration.Update, + // so this catches DbUpdateException, DbUpdateConcurrencyException, + // MySqlException and friends, which the old "Could not parse + // PlanText" message mislabelled while losing the type and stack. + // Capture the exception itself, and only that. + // + // The DayLockedException filter matches the other site: a lock + // refusal is routine, not an incident, and must not be reported. + // It is a control-flow change, but not an outcome change -- the + // rejected entry stays tracked either way, so the sole caller's own + // Update (TimePlanningWorkingHoursService.UpdatePlanning) rethrows + // it whether or not this catch swallowed it. + // + // This method takes no ILogger, so Sentry carries the detail. (The + // sibling UpdatePlanRegistrationsInPeriod in this class does take + // one and logs there.) + catch (Exception e) when (e is not DayLockedException) { - SentrySdk.CaptureMessage( - $"Could not parse PlanText for planning with id: {planRegistration.Id} the PlanText was: {planRegistration.PlanText}"); + SentrySdk.CaptureException(e); } // } return planRegistration; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index c137ec3c2..7ce74cc3a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -356,5 +356,29 @@ internal static string SuccessfullyUnlockedDay { return ResourceManager.GetString("SuccessfullyUnlockedDay", resourceCulture); } } + + internal static string SiteNotFound { + get { + return ResourceManager.GetString("SiteNotFound", resourceCulture); + } + } + + internal static string Imported { + get { + return ResourceManager.GetString("Imported", resourceCulture); + } + } + + internal static string ImportLockedDaysSkipped { + get { + return ResourceManager.GetString("ImportLockedDaysSkipped", resourceCulture); + } + } + + internal static string ImportUnresolvableSheetsSkipped { + get { + return ResourceManager.GetString("ImportUnresolvableSheetsSkipped", resourceCulture); + } + } } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index 0af504001..a2907d06c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -225,4 +225,16 @@ Dagen er låst op + + Medarbejder ikke fundet. + + + Importeret. + + + {0} låst(e) dag(e) blev ikke ændret. + + + Ark ikke importeret, fordi medarbejderen ikke er fuldt oprettet: {0}. + \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index ebaf692a3..ee849fc8f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -225,4 +225,16 @@ Day unlocked + + Worker not found. + + + Imported. + + + Locked days left unchanged: {0}. + + + Sheets not imported because the worker is not fully registered: {0}. + \ No newline at end of file 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 bc12b1674..0eee56dc8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -2369,11 +2369,17 @@ private static void AddLockedPlaceholderDays( /// /// Sets Reconciled and ReconciledAt together and saves. I1: flag and - /// timestamp always change together. DateTime.Now, not UtcNow: the - /// tooltip renders this verbatim as "Afstemt kl. ", and UTC - /// would read 1-2 hours off in Danish time. Consistent with the - /// CanReconcile comparison. Can throw DayLockedException; callers decide - /// how to handle that race. + /// timestamp always change together. Can throw DayLockedException; callers + /// decide how to handle that race. + /// + /// ReconciledAt is an AUDIT/DISPLAY instant -- the tooltip renders it + /// verbatim as "Afstemt kl. " -- never an input to a lock + /// comparison, so it is deliberately NOT covered by + /// DayLockHelper.CanReconcile's UtcNow rule. DateTime.Now is left here + /// unchanged: this repo's Dockerfile sets no TZ, so the shipped container + /// resolves it to UTC anyway and the stored instant is the same either way. + /// Which clock the tooltip ought to render is a presentation question and + /// is tracked separately. /// private async Task SetReconciledAsync(PlanRegistration planning, bool reconciled) { 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 ed5cee6d4..c5d78afb8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -1147,23 +1147,62 @@ await pushNotificationService.SendToSiteAsync( if (dbAssignedSite.UseGoogleSheetAsDefault) return new OperationResult(true, localizationService.GetString("AssignedSiteUpdatedSuccessfuly")); + // NO DAY-LOCK GUARD IS NEEDED HERE, and this is not an oversight. + // Everything below only reads, creates or writes rows with + // Date >= today (UTC midnight). A locked day is always strictly in the + // past: PlanRegistration.Reconciled has exactly one writer + // (TimePlanningPlanningService.SetReconciledAsync), and the only two + // callers that set it TRUE -- Reconcile and ReconcileThrough -- both + // gate on DayLockHelper.CanReconcile, which requires date < UtcNow.Date. + // (Unreconcile is the third caller and only ever sets it false, which + // removes a boundary.) So no boundary can be today or later, whichever + // order days were reconciled in. Both sides compare against UtcNow.Date, + // which makes the two ranges disjoint: no write from here can reach a + // locked row. + var midnight = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, 0, 0, 0); + var planRegistrationsFromTodayAndForward = await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.SdkSitId == siteId) + .Where(x => x.Date >= midnight) + .OrderBy(x => x.Date) + .ToListAsync(); + + if (planRegistrationsFromTodayAndForward.Count == 0) { - var midnight = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, 0, 0, 0); - var planRegistrationsFromTodayAndForward = await dbContext.PlanRegistrations - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.SdkSitId == siteId) - .Where(x => x.Date >= midnight) - .OrderBy(x => x.Date) - .ToListAsync(); + // create new plannings for 30 days as of today and forward + for (int i = 0; i < 30; i++) + { + var newPlanRegistration = new PlanRegistration + { + Date = midnight.AddDays(i), + SdkSitId = siteId, + CreatedByUserId = userService.UserId, + UpdatedByUserId = userService.UserId + }; - if (planRegistrationsFromTodayAndForward.Count == 0) + await newPlanRegistration.Create(dbContext); + } + } else + { + if (planRegistrationsFromTodayAndForward.Count < 30) { - // create new plannings for 30 days as of today and forward + // we need to fill all the gaps from today and forward with a new planning + var datesInPeriod = planRegistrationsFromTodayAndForward.Select(x => x.Date).ToList(); + var missingDates = new List(); for (int i = 0; i < 30; i++) + { + var date = midnight.AddDays(i); + if (!datesInPeriod.Contains(date)) + { + missingDates.Add(date); + } + } + + foreach (var missingDate in missingDates) { var newPlanRegistration = new PlanRegistration { - Date = midnight.AddDays(i), + Date = missingDate, SdkSitId = siteId, CreatedByUserId = userService.UserId, UpdatedByUserId = userService.UserId @@ -1171,207 +1210,178 @@ await pushNotificationService.SendToSiteAsync( await newPlanRegistration.Create(dbContext); } - } else + } + } + + planRegistrationsFromTodayAndForward = await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.SdkSitId == siteId) + .Where(x => x.Date >= midnight) + .OrderBy(x => x.Date) + .ToListAsync(); + + foreach (var planRegistration in planRegistrationsFromTodayAndForward) + { + var dayOfWeek = planRegistration.Date.DayOfWeek; + switch (dayOfWeek) { - if (planRegistrationsFromTodayAndForward.Count < 30) - { - // we need to fill all the gaps from today and forward with a new planning - var datesInPeriod = planRegistrationsFromTodayAndForward.Select(x => x.Date).ToList(); - var missingDates = new List(); - for (int i = 0; i < 30; i++) + case DayOfWeek.Monday: + planRegistration.PlanHours = dbAssignedSite.MondayPlanHours != 0 ? (double)dbAssignedSite.MondayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) { - var date = midnight.AddDays(i); - if (!datesInPeriod.Contains(date)) - { - missingDates.Add(date); - } + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartMonday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndMonday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakMonday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartMonday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndMonday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakMonday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartMonday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndMonday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakMonday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartMonday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndMonday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakMonday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartMonday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndMonday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakMonday5ThShift ?? 0; } - foreach (var missingDate in missingDates) + break; + case DayOfWeek.Tuesday: + planRegistration.PlanHours = dbAssignedSite.TuesdayPlanHours != 0 ? (double)dbAssignedSite.TuesdayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) { - var newPlanRegistration = new PlanRegistration - { - Date = missingDate, - SdkSitId = siteId, - CreatedByUserId = userService.UserId, - UpdatedByUserId = userService.UserId - }; - - await newPlanRegistration.Create(dbContext); + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartTuesday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndTuesday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakTuesday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartTuesday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndTuesday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakTuesday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartTuesday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndTuesday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakTuesday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartTuesday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndTuesday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakTuesday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartTuesday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndTuesday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakTuesday5ThShift ?? 0; } - } - } - - planRegistrationsFromTodayAndForward = await dbContext.PlanRegistrations - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.SdkSitId == siteId) - .Where(x => x.Date >= midnight) - .OrderBy(x => x.Date) - .ToListAsync(); - foreach (var planRegistration in planRegistrationsFromTodayAndForward) - { - var dayOfWeek = planRegistration.Date.DayOfWeek; - switch (dayOfWeek) - { - case DayOfWeek.Monday: - planRegistration.PlanHours = dbAssignedSite.MondayPlanHours != 0 ? (double)dbAssignedSite.MondayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartMonday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndMonday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakMonday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartMonday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndMonday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakMonday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartMonday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndMonday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakMonday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartMonday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndMonday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakMonday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartMonday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndMonday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakMonday5ThShift ?? 0; - } - - break; - case DayOfWeek.Tuesday: - planRegistration.PlanHours = dbAssignedSite.TuesdayPlanHours != 0 ? (double)dbAssignedSite.TuesdayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartTuesday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndTuesday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakTuesday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartTuesday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndTuesday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakTuesday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartTuesday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndTuesday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakTuesday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartTuesday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndTuesday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakTuesday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartTuesday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndTuesday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakTuesday5ThShift ?? 0; - } - - break; - case DayOfWeek.Wednesday: - planRegistration.PlanHours = dbAssignedSite.WednesdayPlanHours != 0 ? (double)dbAssignedSite.WednesdayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartWednesday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndWednesday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakWednesday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartWednesday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndWednesday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakWednesday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartWednesday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndWednesday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakWednesday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartWednesday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndWednesday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakWednesday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartWednesday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndWednesday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakWednesday5ThShift ?? 0; - } - - break; - case DayOfWeek.Thursday: - planRegistration.PlanHours = dbAssignedSite.ThursdayPlanHours != 0 ? (double)dbAssignedSite.ThursdayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartThursday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndThursday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakThursday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartThursday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndThursday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakThursday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartThursday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndThursday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakThursday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartThursday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndThursday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakThursday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartThursday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndThursday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakThursday5ThShift ?? 0; - } + break; + case DayOfWeek.Wednesday: + planRegistration.PlanHours = dbAssignedSite.WednesdayPlanHours != 0 ? (double)dbAssignedSite.WednesdayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) + { + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartWednesday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndWednesday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakWednesday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartWednesday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndWednesday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakWednesday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartWednesday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndWednesday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakWednesday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartWednesday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndWednesday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakWednesday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartWednesday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndWednesday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakWednesday5ThShift ?? 0; + } - break; - case DayOfWeek.Friday: - planRegistration.PlanHours = dbAssignedSite.FridayPlanHours != 0 ? (double)dbAssignedSite.FridayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartFriday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndFriday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakFriday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartFriday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndFriday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakFriday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartFriday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndFriday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakFriday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartFriday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndFriday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakFriday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartFriday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndFriday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakFriday5ThShift ?? 0; - } + break; + case DayOfWeek.Thursday: + planRegistration.PlanHours = dbAssignedSite.ThursdayPlanHours != 0 ? (double)dbAssignedSite.ThursdayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) + { + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartThursday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndThursday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakThursday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartThursday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndThursday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakThursday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartThursday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndThursday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakThursday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartThursday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndThursday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakThursday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartThursday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndThursday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakThursday5ThShift ?? 0; + } - break; - case DayOfWeek.Saturday: - planRegistration.PlanHours = dbAssignedSite.SaturdayPlanHours != 0 ? (double)dbAssignedSite.SaturdayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartSaturday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndSaturday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakSaturday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartSaturday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndSaturday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakSaturday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartSaturday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndSaturday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakSaturday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartSaturday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndSaturday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakSaturday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartSaturday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndSaturday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakSaturday5ThShift ?? 0; - } + break; + case DayOfWeek.Friday: + planRegistration.PlanHours = dbAssignedSite.FridayPlanHours != 0 ? (double)dbAssignedSite.FridayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) + { + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartFriday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndFriday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakFriday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartFriday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndFriday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakFriday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartFriday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndFriday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakFriday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartFriday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndFriday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakFriday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartFriday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndFriday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakFriday5ThShift ?? 0; + } - break; - case DayOfWeek.Sunday: - planRegistration.PlanHours = dbAssignedSite.SundayPlanHours != 0 ? (double)dbAssignedSite.SundayPlanHours / 60 : 0; - if (!dbAssignedSite.UseOnlyPlanHours) - { - planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartSunday ?? 0; - planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndSunday ?? 0; - planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakSunday ?? 0; - planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartSunday2NdShift ?? 0; - planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndSunday2NdShift ?? 0; - planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakSunday2NdShift ?? 0; - planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartSunday3RdShift ?? 0; - planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndSunday3RdShift ?? 0; - planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakSunday3RdShift ?? 0; - planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartSunday4ThShift ?? 0; - planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndSunday4ThShift ?? 0; - planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakSunday4ThShift ?? 0; - planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartSunday5ThShift ?? 0; - planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndSunday5ThShift ?? 0; - planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakSunday5ThShift ?? 0; - } + break; + case DayOfWeek.Saturday: + planRegistration.PlanHours = dbAssignedSite.SaturdayPlanHours != 0 ? (double)dbAssignedSite.SaturdayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) + { + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartSaturday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndSaturday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakSaturday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartSaturday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndSaturday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakSaturday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartSaturday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndSaturday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakSaturday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartSaturday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndSaturday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakSaturday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartSaturday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndSaturday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakSaturday5ThShift ?? 0; + } - break; - } + break; + case DayOfWeek.Sunday: + planRegistration.PlanHours = dbAssignedSite.SundayPlanHours != 0 ? (double)dbAssignedSite.SundayPlanHours / 60 : 0; + if (!dbAssignedSite.UseOnlyPlanHours) + { + planRegistration.PlannedStartOfShift1 = dbAssignedSite.StartSunday ?? 0; + planRegistration.PlannedEndOfShift1 = dbAssignedSite.EndSunday ?? 0; + planRegistration.PlannedBreakOfShift1 = dbAssignedSite.BreakSunday ?? 0; + planRegistration.PlannedStartOfShift2 = dbAssignedSite.StartSunday2NdShift ?? 0; + planRegistration.PlannedEndOfShift2 = dbAssignedSite.EndSunday2NdShift ?? 0; + planRegistration.PlannedBreakOfShift2 = dbAssignedSite.BreakSunday2NdShift ?? 0; + planRegistration.PlannedStartOfShift3 = dbAssignedSite.StartSunday3RdShift ?? 0; + planRegistration.PlannedEndOfShift3 = dbAssignedSite.EndSunday3RdShift ?? 0; + planRegistration.PlannedBreakOfShift3 = dbAssignedSite.BreakSunday3RdShift ?? 0; + planRegistration.PlannedStartOfShift4 = dbAssignedSite.StartSunday4ThShift ?? 0; + planRegistration.PlannedEndOfShift4 = dbAssignedSite.EndSunday4ThShift ?? 0; + planRegistration.PlannedBreakOfShift4 = dbAssignedSite.BreakSunday4ThShift ?? 0; + planRegistration.PlannedStartOfShift5 = dbAssignedSite.StartSunday5ThShift ?? 0; + planRegistration.PlannedEndOfShift5 = dbAssignedSite.EndSunday5ThShift ?? 0; + planRegistration.PlannedBreakOfShift5 = dbAssignedSite.BreakSunday5ThShift ?? 0; + } - await planRegistration.Update(dbContext); + break; } + + await planRegistration.Update(dbContext); } return new OperationResult(true, localizationService.GetString("AssignedSiteUpdatedSuccessfuly")); 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 d882c9063..57e1cf977 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -1365,19 +1365,36 @@ private static void ApplyPunchClockFlexChainDecimal( } /// - /// The day-lock guard for both UpdateWorkingHour overloads: a failure to - /// return when the day is locked, else null. Neither overload has a - /// try/catch, so this answers with a message instead of letting the - /// interceptor throw. The message follows the planning service's rule, the - /// row's own Reconciled flag, so mobile says what web says for the same day + /// The write guard for both UpdateWorkingHour overloads: a failure to + /// return when this (site, day) may NOT be written, else null. Neither + /// overload has a try/catch, so this answers with a message instead of + /// letting the interceptor throw. + /// + /// Named for what it answers, not for one of its reasons: it refuses on two + /// distinct grounds -- the day is locked, or the site could not be resolved + /// at all -- and only the first is a day-lock outcome. (It is also not the + /// same method as TimePlanningPlanningService.CheckDayLockAsync, which + /// takes a loaded PlanRegistration; an earlier audit flagged the shared + /// name as a trap.) + /// + /// Locked case: the message follows the planning service's rule, the row's + /// own Reconciled flag, so mobile says what web says for the same day /// (spec §11.3). The row may not be loaded yet (or may not exist), so the /// flag costs one cheap query, and only when the day is locked. + /// + /// Null site id REFUSES. null is this method's "writable, proceed" answer, + /// so returning it for an unresolvable site would waive the freeze for + /// exactly the caller whose day cannot be evaluated -- and for a rule whose + /// purpose is "this day cannot be written", the unknown case must refuse, + /// not permit. It costs nothing: the kiosk overload dereferences the same + /// id with `!` a few hundred lines later, so a null that got past here + /// ended as a raw InvalidOperationException instead of a message. /// - private async Task CheckDayLockAsync(int? sdkSitId, DateTime date) + private async Task RefuseIfNotWritableAsync(int? sdkSitId, DateTime date) { if (sdkSitId is not { } siteId) { - return null; + return new OperationResult(false, localizationService.GetString("SiteNotFound")); } var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, siteId); @@ -1451,9 +1468,9 @@ public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpd localizationService.GetString("EditingNotAllowedForWorker")); } - if (await CheckDayLockAsync(sdkSite.MicrotingUid, model.Date) is { } dayLocked) + if (await RefuseIfNotWritableAsync(sdkSite.MicrotingUid, model.Date) is { } refusal) { - return dayLocked; + return refusal; } var todayAtMidnight = model.Date; @@ -2086,9 +2103,9 @@ public async Task UpdateWorkingHour(int? sdkSiteId, TimePlannin // Before both branches below (each repeats its own assigned-site lookup), // and after the token check so an unknown device learns nothing about // the lock. - if (await CheckDayLockAsync(sdkSiteId, model.Date) is { } dayLocked) + if (await RefuseIfNotWritableAsync(sdkSiteId, model.Date) is { } refusal) { - return dayLocked; + return refusal; } registrationDevice.OsVersion = model.OsVersion; @@ -3993,6 +4010,17 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) public async Task Import(IFormFile file) { + // Method scope, so the finally below and the success exit at the bottom + // can both read them. The two counters are incremented at DIFFERENT + // levels, so an audit has to look in both places: + // unresolvableSheetsSkipped -- the sheet loop, at the third of its + // three `continue`s (the first two are deliberately silent; each + // says why). + // lockedDaysSkipped -- the nested ROW loop, at its locked-day + // `continue`. This is the counter the deleted Console.WriteLine + // used to report. + var lockedDaysSkipped = 0; + var unresolvableSheetsSkipped = 0; try { // Get core @@ -4017,20 +4045,38 @@ public async Task Import(IFormFile file) return new OperationResult(false, localizationService.GetString("FileFormatError")); } - // Observability only: the locked-day skip below is silent otherwise. - var lockedDaysSkipped = 0; foreach (Sheet sheet in sheets) { + // A malformed sheet element, not user data. Silent. if (sheet.Name?.Value == null || sheet.Id?.Value == null) { continue; } var site = await sdkContext.Sites.FirstOrDefaultAsync(x => x.Name.Replace(" ", "").ToLower() == sheet.Name.Value.Replace(" ", "").ToLower()); + + // DELIBERATELY SILENT, and not the same case as the one + // below. A sheet whose name matches no worker may not be + // about a worker at all -- a cover tab, instructions, a + // summary -- so reporting every one would cry wolf on + // ordinary workbooks. (Pre-existing behaviour; if it is + // ever revisited, it needs its own decision about which + // unmatched names are worth naming.) if (site == null) { continue; } + // REPORTED, because the name DID match a worker: this is + // a real data problem the user has to hear about, not an + // unrelated tab. Skip the whole sheet -- a null boundary + // makes IsLocked false for EVERY row, which would import + // it with no lock check at all. + if (site.MicrotingUid is not { } importSiteUid) + { + unresolvableSheetsSkipped++; + continue; + } + // ONE timeline per sheet (= per site), built BEFORE the row // loop and never per row. The update leg below may only clear // a row's seconds columns once it knows the row ran in @@ -4038,15 +4084,14 @@ public async Task Import(IFormFile file) var importAssignedSite = await dbContext.AssignedSites .AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .FirstOrDefaultAsync(x => x.SiteId == site.MicrotingUid); + .FirstOrDefaultAsync(x => x.SiteId == importSiteUid); var importTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, importAssignedSite); // A bulk import skips locked days (frozen means frozen) // rather than failing the whole file. Once per sheet // (= per site), never per row. - var importLockedThrough = site.MicrotingUid is { } importSiteUid - ? await DayLockHelper.LockedThroughAsync(dbContext, importSiteUid) - : null; + var importLockedThrough = + await DayLockHelper.LockedThroughAsync(dbContext, importSiteUid); var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value); var sheetData = worksheetPart.Worksheet.Elements().First(); @@ -4110,12 +4155,12 @@ public async Task Import(IFormFile file) var preTimePlanning = await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.Date < dateValue && x.SdkSitId == (int)site.MicrotingUid!) + .Where(x => x.Date < dateValue && x.SdkSitId == importSiteUid) .OrderByDescending(x => x.Date) .FirstOrDefaultAsync(); var planRegistration = await dbContext.PlanRegistrations.FirstOrDefaultAsync(x => - x.Date == dateValue && x.SdkSitId == site.MicrotingUid + x.Date == dateValue && x.SdkSitId == importSiteUid && x.WorkflowState != Constants.WorkflowStates.Removed); if (planRegistration == null) @@ -4125,7 +4170,7 @@ public async Task Import(IFormFile file) Date = dateValue, PlanText = planText, PlanHours = parsedPlanHours, - SdkSitId = (int)site.MicrotingUid!, + SdkSitId = importSiteUid, CreatedByUserId = userService.UserId, UpdatedByUserId = userService.UserId, NettoHours = 0, @@ -4196,8 +4241,6 @@ public async Task Import(IFormFile file) } } } - - Console.WriteLine($"[Import] summary: skipped {lockedDaysSkipped} locked day(s)."); } } } @@ -4207,7 +4250,56 @@ public async Task Import(IFormFile file) logger.LogError(ex.Message); return new OperationResult(false, ex.Message); } - return new OperationResult(true, "Imported"); + finally + { + // IN A FINALLY, so it runs on EVERY exit from this method: the + // success path below, the catch above, and the two malformed-file + // early returns. Placed after the catch's `return` it would have + // been skipped exactly when it matters most -- a file that skipped + // 40 locked days and then threw on sheet 9 would lose both counts. + // + // This is the channel ops actually has: it works whatever the UI + // does with the response, and it replaces the Console.WriteLine + // this PR removed. It logs even when both counts are zero, so + // "skipped nothing" stays distinguishable from "never got here". + // "ended", not "finished": on the catch path it did not finish. + logger.LogInformation( + "Import ended: {LockedDaysSkipped} locked day(s) skipped, {UnresolvableSheetsSkipped} sheet(s) skipped for a worker with no MicrotingUid.", + lockedDaysSkipped, unresolvableSheetsSkipped); + } + + // A sheet whose name matched a Site row that carries no MicrotingUid is + // a data problem someone has to fix, not routine bookkeeping, so it also + // raises a Sentry warning -- the same treatment GoogleSheetHelper gives + // its own sheet problems. Only when non-zero: a zero is worth nothing + // there. Only on the success path, too: a run that threw has already + // reported that exception to Sentry, and the finally above carries the + // counts, so repeating them here would be a second ticket for one run. + if (unresolvableSheetsSkipped > 0) + { + SentrySdk.CaptureMessage( + $"Import: {unresolvableSheetsSkipped} sheet(s) named a worker with no MicrotingUid and were skipped.", + SentryLevel.Warning); + } + + // Then the user-facing message: one success exit, naming each skip with + // its reason -- the rule ReconcileThrough already follows. A bare + // "Imported" over a reconciled month reads as "your corrections are in" + // while the locked rows still hold their old numbers, so the user + // re-imports the same file forever. Each part is a whole sentence, so + // they join in any language without having to agree grammatically. + var summary = new List { localizationService.GetString("Imported") }; + if (lockedDaysSkipped > 0) + { + summary.Add(localizationService.GetString( + "ImportLockedDaysSkipped", lockedDaysSkipped)); + } + if (unresolvableSheetsSkipped > 0) + { + summary.Add(localizationService.GetString( + "ImportUnresolvableSheetsSkipped", unresolvableSheetsSkipped)); + } + return new OperationResult(true, string.Join(" ", summary)); } private string GetCellValue(WorkbookPart workbookPart, Row row, int columnIndex) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/working-hours/components/working-hours-actions/working-hours-upload-modal/working-hours-upload-modal.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/working-hours/components/working-hours-actions/working-hours-upload-modal/working-hours-upload-modal.component.ts index b6ca73ba7..960746bf9 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/working-hours/components/working-hours-actions/working-hours-upload-modal/working-hours-upload-modal.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/working-hours/components/working-hours-actions/working-hours-upload-modal/working-hours-upload-modal.component.ts @@ -41,10 +41,37 @@ export class WorkingHoursUploadModalComponent implements OnInit { // this.workingHoursFileUploader.onBuildItemForm = (item, form) => { // //form.append('templateId', this.selectedTemplate.id); // }; - this.workingHoursFileUploader.onSuccessItem = () => { + // HTTP 200 DOES NOT MEAN THE IMPORT SUCCEEDED. The controller returns an + // OperationResult as a 200 body whatever its Success flag says, so + // ng2-file-upload routes server-reported FAILURES -- a malformed workbook, + // or an exception part way through the file -- to onSuccessItem, not to + // onErrorItem. Branch on the body, never on the transport. + // + // On success the body also reports what the import actually did: how many + // days it could not write because they are reconciled, and how many sheets + // belong to a worker with no id. Those rows are silently NOT imported, so a + // hardcoded "uploaded successfully" would tell someone their corrections + // went in when they did not, and they would re-upload the same file forever. + this.workingHoursFileUploader.onSuccessItem = (item, response) => { this.workingHoursFileUploader.clearQueue(); + const result = this.parseOperationResult(response); + if (result && !result.success) { + // Report the server's own diagnosis and stop: no success toast, and the + // dialog is not dismissed as done -- same shape as onErrorItem below. + this.toastrService.error( + result.message ?? + this.translateService.instant('Error while uploading file') + ); + return; + } + // Green even when the message carries skips ("Imported. Locked days left + // unchanged: 3."). A warning toast would read better, but the only signal + // here is a localized string, and sniffing it for skip text is fragile. + // Doing it properly means the endpoint returning counts as data -- + // OperationDataResult with a typed model, the way ReconcileThrough does. this.toastrService.success( - this.translateService.instant('File has been uploaded successfully') + result?.message ?? + this.translateService.instant('File has been uploaded successfully') ); this.hideZipModal(true); }; @@ -61,9 +88,41 @@ export class WorkingHoursUploadModalComponent implements OnInit { }; } + /** + * The server's OperationResult, parsed ONCE so the caller can branch on + * success and reuse the message, or null when the body is not a recognisable + * OperationResult. Defensive on purpose: ng2-file-upload hands back the raw + * response text, and a proxy or an error page can put anything in it. A null + * result means "cannot tell", which the caller treats as the old + * success-with-translated-fallback behaviour. + */ + private parseOperationResult( + response: string + ): { success: boolean; message: string | null } | null { + try { + const body = JSON.parse(response); + if (!body || typeof body.success !== 'boolean') { + return null; + } + return { + success: body.success, + message: + typeof body.message === 'string' && body.message ? body.message : null, + }; + } catch { + return null; + } + } + uploadTemplateZIP() { this.workingHoursFileUploader.queue[0].upload(); this.dialogRef.close(true); + // DO NOT merge this with the toast in onSuccessItem. The two say different + // things at different times and both are wanted: this one fires the moment + // the upload STARTS and is honest that processing takes a while, while + // onSuccessItem fires when the server has finished and reports what the + // import actually did. Collapsing them would either lose the "this takes a + // while" warning or make the dialog block until the import completes. this.toastrService.success( this.translateService.instant('File has been uploaded successfully, processing file can take a while, depending on the number of records') );