From 1f25415d03876b07179a5cc4033b41d54a5bc318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 25 Jun 2026 11:20:06 +0200 Subject: [PATCH 1/5] docs(spec): admin pause override (non-destructive) design Approach C: per-shift Pause{N}OverrideMinutes override that ComputeShiftPauseSeconds honors, preserving the worker's recorded pause start/stops for documentation. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-25-pause-override-design.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-pause-override-design.md diff --git a/docs/superpowers/specs/2026-06-25-pause-override-design.md b/docs/superpowers/specs/2026-06-25-pause-override-design.md new file mode 100644 index 00000000..fe35f6e9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-pause-override-design.md @@ -0,0 +1,61 @@ +# Admin pause override (non-destructive) — design + +**Date:** 2026-06-25 +**Status:** design approved (approach), pending spec review → plan → implementation +**Branch:** `feat/pause-override` off `stable` (plugin); base changes in `eform-timeplanning-base`. + +## Problem + +After #1626 made pause timestamp sub-slots the source of truth (`ComputeShiftPauseSeconds` sums all slots; legacy `Pause{N}Id` is only a fallback when a shift has no timestamped slot), the admin web workday dialog can no longer change the effective total pause for a shift that has punch-clock sub-slots: + +- On the common config (`UseDetailedPauseEditing=false`, `UseOneMinuteIntervals=false`) the admin's typed value is written to `Pause1Id` but then **ignored** — `ComputeTimeTrackingFields → ComputeShiftPauseSeconds` sees the surviving sub-slot timestamps (`hasTimestampedSlot=true`) and sums them. +- On `UseOneMinuteIntervals=true` sites the edit "works" only because `ApplyExactMinutePause()` **destroys** the sub-slots (clears them, writes one synthesized pause). + +**Requirement (product owner):** the admin must be able to override the per-shift total pause, AND the worker's individual pause start/stop times must be **preserved** for documentation of what the worker actually did. So the destructive collapse (Approach A) is rejected; the one-minute path's existing clear-on-edit must also stop destroying the record. + +## Approach C — override layer (preserve all start/stops) + +Keep every recorded `Pause{N}StartedAt/StoppedAt` (and sub-slots) untouched. Store the admin's per-shift total as a separate **override** that the single pause-computation chokepoint honors. + +### Data model (base package `eform-timeplanning-base`) + +Add to entity `PlanRegistration` **and** `PlanRegistrationVersion` (both — the versioned/audit entity must match or the EF model-diff CI check fails, same lesson as the OverMidnight `AssignedSiteVersion` catch): + +- `Pause1OverrideMinutes` … `Pause5OverrideMinutes` — `int?` (nullable). `null` = no override (compute from slots as today); non-null = authoritative total minutes for that shift. Nullable doubles as the "active" flag, so no separate bool column. + +EF Core migration in the base package (no raw SQL). Bump base version, publish to NuGet. Canonical base repo: `/home/rene/laptop/Documents/workspace/microting/eform-timeplanning-base` (the `/Documents/` copy is stale — confirm at implementation time). + +### Computation (plugin `PlanRegistrationHelper.cs`) + +`ComputeShiftPauseSeconds(r, shift, useOneMinuteIntervals)`: **first** check `Pause{shift}OverrideMinutes`; if non-null, return `value * 60` (seconds). Otherwise the current all-slots sum / legacy fallback. This single chokepoint means netto (`ComputeNettoSecondsFromDateTimeShifts`, `ComputeTimeTrackingFields`), the display field (`AggregatePauseMinutes`), and the Excel export all honor the override automatically. The legacy `ComputePlanningNettoMinutes` flag-off path must also honor the override for `NettoHours` consistency. + +### Save path (plugin `TimePlanningPlanningService.cs`) + +- `Update` / `UpdateByCurrentUserNam`: when the admin sets a per-shift pause, write `Pause{N}OverrideMinutes` (from the exact minutes for one-minute sites; from `(Pause{N}Id-1)*5` for flag-off sites — sentinel-aware). **Do not** clear or synthesize sub-slot timestamps. +- The `UseOneMinuteIntervals` branch must **stop calling** the destructive `ApplyExactMinutePause()`/`ClearPauseTimestamps()` and set the override instead — so one-minute sites also retain the worker's recorded pauses. +- Clearing the field in the dialog (empty pause) sets the override back to `null` (revert to computed-from-slots). A worker re-syncing new pauses from the device is unaffected unless an override is active; define precedence as override-wins-while-set (admin intent is explicit). + +### Web (`workday-entity-dialog.component.ts`) + +- The per-shift pause edit writes `pause{N}OverrideMinutes` on the model (new DTO field) instead of relying on `pause1Id`. Raw sub-slot timestamps continue to round-trip untouched. +- Display already sums slots (post-#1626); when an override is present the served model carries it and the field shows the override value. + +### Transport / DTO + +Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) and the Angular model. gRPC/mobile transport: out of scope unless the mobile app needs to read it (it doesn't edit admin overrides) — confirm at implementation; if the proto carries pause fields, add the override there too for parity, else skip. + +## Out of scope (YAGNI) +- No per-slot editing UI (Approach B). +- No collapse/destructive reset (Approach A) — explicitly rejected by the documentation requirement. +- Shifts 3–5 get the columns for uniformity but the UI primarily exercises 1–2. + +## Phasing (dependency order) +1. **Base package:** entities + migration + version bump + publish NuGet (gate: published before plugin can consume). +2. **Plugin C#:** bump base dep; `ComputeShiftPauseSeconds` + `ComputePlanningNettoMinutes` honor override; save path writes override and stops the destructive clear; DTO fields; `Integration.Test/SQL/420_*.sql` dump updated for the new columns; tests (override wins; slots preserved; revert-on-null). +3. **Web:** dialog writes `pause{N}OverrideMinutes`; Angular model field. +4. Each phase: dual gate (code-review + simplifier) → PR → CI green → merge. Land in order. + +## Verification +- Unit: `ComputeShiftPauseSeconds` returns override when set, sums slots when null; netto/display/export reflect it. +- Integration: admin edits pause on a multi-pause row → effective total changes, `Pause{N}StartedAt/StoppedAt` rows unchanged in DB (documentation intact). +- Manual: edit pause for a punch-clock day; confirm the per-pause history is still present in the data. From 99378fa1983246d27bcffb519f2b5ecb5f4f6852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 25 Jun 2026 11:46:46 +0200 Subject: [PATCH 2/5] docs(spec): bring mobile (proto + flutter read/write) into pause-override scope Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-25-pause-override-design.md | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-pause-override-design.md b/docs/superpowers/specs/2026-06-25-pause-override-design.md index fe35f6e9..c8c1a758 100644 --- a/docs/superpowers/specs/2026-06-25-pause-override-design.md +++ b/docs/superpowers/specs/2026-06-25-pause-override-design.md @@ -23,7 +23,7 @@ Add to entity `PlanRegistration` **and** `PlanRegistrationVersion` (both — the - `Pause1OverrideMinutes` … `Pause5OverrideMinutes` — `int?` (nullable). `null` = no override (compute from slots as today); non-null = authoritative total minutes for that shift. Nullable doubles as the "active" flag, so no separate bool column. -EF Core migration in the base package (no raw SQL). Bump base version, publish to NuGet. Canonical base repo: `/home/rene/laptop/Documents/workspace/microting/eform-timeplanning-base` (the `/Documents/` copy is stale — confirm at implementation time). +EF Core migration in the base package (no raw SQL). Bump base version, publish to NuGet. Canonical base repo: `/home/rene/Documents/workspace/microting/eform-timeplanning-base` (confirmed by owner; the `/laptop/` copy referenced in the 2026-06-19 handoff is NOT the one to use). ### Computation (plugin `PlanRegistrationHelper.cs`) @@ -42,7 +42,17 @@ EF Core migration in the base package (no raw SQL). Bump base version, publish t ### Transport / DTO -Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) and the Angular model. gRPC/mobile transport: out of scope unless the mobile app needs to read it (it doesn't edit admin overrides) — confirm at implementation; if the proto carries pause fields, add the override there too for parity, else skip. +Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) and the Angular model. + +**Mobile / gRPC — IN SCOPE (owner requirement).** The flutter app needs the override for (a) presenting corrected pause history to all workers and (b) the back-in-time editors who can amend past registrations. So: +- Add `pause{N}_override_minutes` to the relevant gRPC message(s) in `proto/` (the PlanningPrDay/working-hours message the app reads, and the update message the app writes when editing back in time). Regenerate C# + Dart proto. +- gRPC service mapping: populate the override on read; persist it on write (same non-destructive rule — writing the override never clears sub-slots). +- Honor the gRPC-only-transport invariant: touch only the gRPC path, not the old JSON/REST oracle. + +### Mobile app (flutter-time) — IN SCOPE + +- **Read (all users):** history/day views display the per-shift pause using the override when present (else the computed sum from #531). One source-of-truth helper, mirroring `ComputeShiftPauseSeconds`: override-wins-else-sum. +- **Write (back-in-time editors only):** where the app permits editing a past shift's pause, write `pause{N}_override_minutes` (non-destructive — recorded start/stops preserved), gated to the roles/flows already allowed to edit back in time. Clearing reverts to null. ## Out of scope (YAGNI) - No per-slot editing UI (Approach B). @@ -50,10 +60,11 @@ Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) a - Shifts 3–5 get the columns for uniformity but the UI primarily exercises 1–2. ## Phasing (dependency order) -1. **Base package:** entities + migration + version bump + publish NuGet (gate: published before plugin can consume). -2. **Plugin C#:** bump base dep; `ComputeShiftPauseSeconds` + `ComputePlanningNettoMinutes` honor override; save path writes override and stops the destructive clear; DTO fields; `Integration.Test/SQL/420_*.sql` dump updated for the new columns; tests (override wins; slots preserved; revert-on-null). +1. **Base package** (`/Documents/...eform-timeplanning-base`): entities (`PlanRegistration` + `PlanRegistrationVersion`) + EF migration + version bump + publish NuGet (gate: published before plugin can consume). +2. **Plugin C#:** bump base dep; `ComputeShiftPauseSeconds` + `ComputePlanningNettoMinutes` honor override; save path writes override and stops the destructive clear; REST DTO fields; **gRPC proto + service mapping (read+write)**; `Integration.Test/SQL/420_*.sql` dump updated for the new columns; tests (override wins; slots preserved; revert-on-null). 3. **Web:** dialog writes `pause{N}OverrideMinutes`; Angular model field. -4. Each phase: dual gate (code-review + simplifier) → PR → CI green → merge. Land in order. +4. **Mobile (flutter-time):** regen Dart proto; read override for history display (override-wins-else-sum helper); write override in the back-in-time edit flow (role-gated, non-destructive). +5. Each phase: dual gate (code-review + simplifier) → PR → CI green → merge. Land in order; protocol/contract (base, then proto in plugin) before consumers (web, mobile). ## Verification - Unit: `ComputeShiftPauseSeconds` returns override when set, sums slots when null; netto/display/export reflect it. From 6647e62aaa5879552997f12e1fc8584e23439ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 25 Jun 2026 11:48:25 +0200 Subject: [PATCH 3/5] docs(spec): pause-override must cover flutter manual (non-punchclock) time edit Co-Authored-By: Claude Opus 4.8 --- .../superpowers/specs/2026-06-25-pause-override-design.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-pause-override-design.md b/docs/superpowers/specs/2026-06-25-pause-override-design.md index c8c1a758..5be469cd 100644 --- a/docs/superpowers/specs/2026-06-25-pause-override-design.md +++ b/docs/superpowers/specs/2026-06-25-pause-override-design.md @@ -51,8 +51,12 @@ Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) a ### Mobile app (flutter-time) — IN SCOPE -- **Read (all users):** history/day views display the per-shift pause using the override when present (else the computed sum from #531). One source-of-truth helper, mirroring `ComputeShiftPauseSeconds`: override-wins-else-sum. -- **Write (back-in-time editors only):** where the app permits editing a past shift's pause, write `pause{N}_override_minutes` (non-destructive — recorded start/stops preserved), gated to the roles/flows already allowed to edit back in time. Clearing reverts to null. +**Unifying principle:** the override is the canonical "manually entered / edited pause total" for a shift. EVERY manual edit surface writes it; punch-clock sub-slots are preserved as documentation; reads are override-wins-else-sum everywhere. + +- **Read (all users):** history/day views display the per-shift pause using the override when present (else the computed sum from #531). One source-of-truth helper mirroring `ComputeShiftPauseSeconds`: override-wins-else-sum. +- **Write — manual time-edit (non-punch-clock) flow:** this is the primary mobile write path and MUST keep working. When a worker/editor on a non-punch-clock site enters/edits a shift's pause manually, write `pause{N}_override_minutes` via the gRPC update (non-destructive). On non-punch-clock days there are typically no sub-slots, so the override simply *is* the pause; previously this relied on the legacy `Pause{N}Id`, which post-#1626/#531 is no longer authoritative when any timestamp exists — the override fixes that uniformly. +- **Write — back-in-time editors:** the same override write applies to the role-gated flow that amends past registrations. Clearing the field reverts to null (compute-from-slots). +- Verify against the shift edit surfaces (the manual edit widget(s) / the 25-clone shift-confirm pages) so manual pause entry routes to the override, not to a now-non-authoritative `Pause{N}Id` or a destructive slot rewrite. ## Out of scope (YAGNI) - No per-slot editing UI (Approach B). From 0b059900ba123f293f5856f88f9909f1d6f2f00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 25 Jun 2026 12:26:18 +0200 Subject: [PATCH 4/5] docs(spec): finalize server-side pause-override mechanism (base v10.0.53 published) Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-25-pause-override-design.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-06-25-pause-override-design.md b/docs/superpowers/specs/2026-06-25-pause-override-design.md index 5be469cd..11835ab6 100644 --- a/docs/superpowers/specs/2026-06-25-pause-override-design.md +++ b/docs/superpowers/specs/2026-06-25-pause-override-design.md @@ -63,8 +63,15 @@ Add `Pause{N}OverrideMinutes` to `TimePlanningPlanningPrDayModel` (read+write) a - No collapse/destructive reset (Approach A) — explicitly rejected by the documentation requirement. - Shifts 3–5 get the columns for uniformity but the UI primarily exercises 1–2. +## Finalized server-side mechanism (no app changes) +- **Distinct RPCs:** manual edit → `UpdatePlanningByCurrentUser`; punch-clock → `UpdateWorkingHours`. No ambiguity. +- **WRITE (infer override, change-detected):** in `UpdateByCurrentUserNam` (and `Update`), compute the shift's current pause via `ComputeShiftPauseSeconds`; if the submitted `BreakNShift` total `(BreakNShift-1)*5` DIFFERS, set `PauseNOverrideMinutes` to it. If unchanged, leave the override as-is (so editing only start/stop never locks an override). NEVER modify the recorded `Pause*StartedAt/StoppedAt`. The destructive `ApplyExactMinutePause`/`ClearPauseTimestamps` on the one-minute path is replaced by setting the override. +- **WEB write:** the dialog sets `PauseNOverrideMinutes` explicitly via a new DTO field (we control that code) — no inference needed there. +- **READ (project for the unchanged app):** on BOTH `GetPlanningsByUser` (history) and `ReadWorkingHours` (today), when `PauseNOverrideMinutes` is set, the gRPC RESPONSE synthesizes a single `PauseNStartedAt/StoppedAt` pair of the override duration (anchored at shift start; fall back to `(StartNId-1)*5` from midnight if no start timestamp), zeroes that shift's sub-slot timestamps IN THE RESPONSE, and sets the `pauseMinutes` aggregate to the override sum. DB rows are untouched (documentation preserved). +- **COMPUTE:** `ComputeShiftPauseSeconds` returns the override when set (else all-slots sum); `ComputePlanningNettoMinutes` (flag-off) honors it too. + ## Phasing (dependency order) -1. **Base package** (`/Documents/...eform-timeplanning-base`): entities (`PlanRegistration` + `PlanRegistrationVersion`) + EF migration + version bump + publish NuGet (gate: published before plugin can consume). +1. **Base package** (`/Documents/...eform-timeplanning-base`): entities (`PlanRegistration` + `PlanRegistrationVersion`) + EF migration + publish. ✅ DONE — merged, tagged, **published v10.0.53**. 2. **Plugin C#:** bump base dep; `ComputeShiftPauseSeconds` + `ComputePlanningNettoMinutes` honor override; save path writes override and stops the destructive clear; REST DTO fields; **gRPC proto + service mapping (read+write)**; `Integration.Test/SQL/420_*.sql` dump updated for the new columns; tests (override wins; slots preserved; revert-on-null). 3. **Web:** dialog writes `pause{N}OverrideMinutes`; Angular model field. 4. **Mobile (flutter-time):** regen Dart proto; read override for history display (override-wins-else-sum helper); write override in the back-in-time edit flow (role-gated, non-destructive). From 0f5fec5037dfd66c4da9c5710195adc79664664a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 25 Jun 2026 13:41:06 +0200 Subject: [PATCH 5/5] feat(pause): per-shift pause override (non-destructive) + server-side app compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets web and the UNCHANGED mobile app set a shift's total pause without destroying the worker's recorded pause start/stops (kept for documentation). Consumes base 10.0.53 Pause{N}OverrideMinutes columns. - ComputeShiftPauseSeconds + ComputePlanningNettoMinutes honor the override (else sum all recorded slots) — one chokepoint → netto, display, export agree. - WRITE (UpdateByCurrentUserNam + Update): infer the override from the app's Break{N}Shift only when it differs from the currently-shown coarse tick (override → (override/5)+1, else Pause{N}Id) — so editing only start/stop never spuriously locks an override and re-saves are idempotent (incl. non-5-min exact overrides). Recorded timestamps never touched. Explicit web override fields (Pause{N}OverrideMinutesSpecified / ClearPauseOverrides) and the one-minute exact-minute path take precedence. - READ projection (GetPlanningsByUser history + ReadWorkingHours today): when an override is set, the gRPC RESPONSE synthesizes a single Pause{N}StartedAt/StoppedAt pair of the override duration, zeroes that shift's sub-slots, and sets Break{N}Shift/Pause{N}Id/Shift{N}Pause + pauseMinutes to the override — so the unchanged app displays it. DB rows untouched (no app or proto change; wire-compatible). - EnsureTimestampsFromIds no longer fabricates Pause{N}StartedAt/StoppedAt for shifts with an active override. - Removed the destructive ApplyExactMinutePause/ClearPauseTimestamps path. - REST DTO override fields for the web dialog (Phase 3). SQL dump + unit and integration tests (incl. the regression: edit pause on a sub-slot row → effective netto reflects edit, slots preserved). Co-Authored-By: Claude Opus 4.8 --- .../ComputeShiftPauseSecondsTests.cs | 336 ++++++++++++++++ .../PlanningServiceMultiShiftTests.cs | 367 ++++++++++++++++++ ...420_eform-angular-time-planning-plugin.sql | 10 + .../Helpers/PlanRegistrationHelper.cs | 313 +++++++++++++++ .../TimePlanningPlanningPrDayModel.cs | 30 ++ .../TimePlanningPlanningService.cs | 359 ++++++++++------- .../TimePlanning.Pn/TimePlanning.Pn.csproj | 2 +- 7 files changed, 1272 insertions(+), 145 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs index 339fea71..86aa0060 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.Planning; +using TimePlanning.Pn.Services.TimePlanningPlanningService; namespace TimePlanning.Pn.Test; @@ -190,4 +193,337 @@ public void PartialTimestamp_StartedAtOnly_FallsBackToLegacyPauseId() Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false), Is.EqualTo(900), "Half-record (StartedAt only) must not suppress the legacy PauseId fallback"); } + + // ---- Approach C: pause override ---- + + /// + /// Override wins over the recorded slot sum. The 16288 shape sums to 30 min + /// across the shift-1 slots, but an override of 12 min must replace it + /// entirely (the slots stay in the entity as documentation, but are not + /// summed). Holds in BOTH flag modes — the override short-circuits before + /// any slot math. + /// + [TestCase(true)] + [TestCase(false)] + public void Override_WinsOverSlotSum(bool useOneMinuteIntervals) + { + var reg = BuildRow16288Shape(); + reg.Pause1OverrideMinutes = 12; + + var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); + + Assert.That(result, Is.EqualTo(12 * 60), + "Override must replace the slot sum entirely."); + } + + /// + /// Override of 0 means an explicit zero pause for the shift, even though the + /// recorded slots would otherwise sum to 30 min. + /// + [TestCase(true)] + [TestCase(false)] + public void Override_Zero_MeansZeroPause(bool useOneMinuteIntervals) + { + var reg = BuildRow16288Shape(); + reg.Pause1OverrideMinutes = 0; + + var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals); + + Assert.That(result, Is.EqualTo(0), + "Override = 0 means zero pause, not the slot sum."); + } + + /// + /// Null override (the default) leaves the existing slot-sum behavior intact: + /// shift 1 OFF = 30 min, exactly as the non-override tests assert. + /// + [Test] + public void Override_Null_FallsBackToSlotSum() + { + var reg = BuildRow16288Shape(); + reg.Pause1OverrideMinutes = null; + + var result = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, useOneMinuteIntervals: false); + + Assert.That(result, Is.EqualTo(30 * 60), + "Null override must fall back to the all-slots sum (30 min)."); + } + + /// + /// A non-null override on shift 1 must NOT leak into shift 2's computation — + /// shift 2 (no override) still sums its own slot (5 min OFF). + /// + [Test] + public void Override_IsPerShift_DoesNotLeakAcrossShifts() + { + var reg = BuildRow16288Shape(); + reg.Pause1OverrideMinutes = 99; + + var shift2 = PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 2, useOneMinuteIntervals: false); + + Assert.That(shift2, Is.EqualTo(5 * 60), + "Shift 2 has no override and must still sum its own slot (5 min)."); + } +} + +/// +/// No-Docker unit locks for the pause-override WRITE path (Approach C, Phase 2): +/// the change-detection inference (FIX 1), the explicit web clear (FIX 2), the +/// exact-minute-wins ordering (FIX 4), and EnsureTimestampsFromIds not fabricating +/// observation timestamps under an active override (FIX 3). These exercise the +/// service's internal helpers directly (InternalsVisibleTo) with no DB. +/// +[TestFixture] +public class PauseOverrideInferenceTests +{ + private static readonly DateTime Date = new(2026, 6, 19, 0, 0, 0, DateTimeKind.Utc); + + private static ISet NoneHandled() => new HashSet(); + + /// + /// FIX 1 (the 16288-shape unit-mismatch bug). A punch-clock row whose recorded + /// slots sum to a NON-5-minute total (33 min on a one-minute site) and whose + /// legacy Pause1Id is 0. The client round-trips Break1Shift = pre-edit Pause1Id + /// = 0 (the value the read path emits) while only start/stop changed. The + /// override must STAY NULL (no spurious lock) and netto must keep reflecting the + /// recorded slot sum. + /// + /// Pre-fix the baseline compared the coarse tick (0) against the exact slot-sum + /// (33); 0 ≠ 33 ALWAYS locked an override on every save. The fix compares + /// Break1Shift against the pre-edit Pause1Id instead. + /// + [Test] + public void Inference_EditOnlyStartStop_NonFiveMinSlotSum_LeavesOverrideNull() + { + var reg = new PlanRegistration + { + Date = Date, + Pause1Id = 0, + // Two recorded sub-slots summing to 33 min exactly (one-minute site). + Pause1StartedAt = Date.AddHours(10), + Pause1StoppedAt = Date.AddHours(10).AddMinutes(13), + Pause10StartedAt = Date.AddHours(11), + Pause10StoppedAt = Date.AddHours(11).AddMinutes(20), + }; + // Sanity: the slot sum is 33 min, not a 5-min multiple. + Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, true), + Is.EqualTo(33 * 60), "Pre-condition: slot sum is 33 min."); + + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + var model = new TimePlanningPlanningPrDayModel { Break1Shift = 0 }; // == pre-edit Pause1Id + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.Null, + "Unchanged pause (Break == pre-edit Pause1Id) must NOT lock an override."); + Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, true), + Is.EqualTo(33 * 60), "Netto pause still reflects the recorded slot sum."); + } + + /// + /// FIX 1: editing the pause to a new coarse value (Break1Shift differs from the + /// pre-edit Pause1Id) DOES set the override to (Break1Shift - 1) * 5 minutes. + /// + [Test] + public void Inference_PauseChanged_SetsOverride() + { + var reg = new PlanRegistration { Date = Date, Pause1Id = 2 }; // pre-edit tick 2 (= 5 min) + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + var model = new TimePlanningPlanningPrDayModel { Break1Shift = 5 }; // (5-1)*5 = 20 min + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.EqualTo(20), + "Changed Break1Shift (5 ≠ pre-edit 2) sets override to (5-1)*5 = 20 min."); + } + + /// + /// IDEMPOTENT RE-SAVE (conf 82 corruption). A shift already carries a + /// non-5-multiple exact override (33 min, set via the one-minute path). The + /// read path serves Break1Shift = (33/5)+1 = 7. On a later UNRELATED save (only + /// start/stop changed) the client round-trips that served tick (7). The + /// captured pre-edit SHOWN tick is also 7, so inference must NOT fire and the + /// exact 33-min override must SURVIVE — not be silently rounded down to + /// (7-1)*5 = 30. (Pre-fix the baseline was the raw Pause1Id = 6, so 7 ≠ 6 fired + /// inference and corrupted 33 → 30 on every save.) + /// + [Test] + public void Inference_ReSaveSameShownTick_NonFiveMinOverride_StaysExact() + { + var reg = new PlanRegistration + { + Date = Date, + Pause1OverrideMinutes = 33, // exact one-minute override (not a 5-multiple) + Pause1Id = 6, // raw legacy tick differs from the served shown tick (7) + }; + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + // Sanity: the captured shown tick mirrors the read projection ((33/5)+1 = 7). + Assert.That(preEditShownTicks[1], Is.EqualTo(7), + "Pre-condition: shown tick = (33/5)+1 = 7 (the value the client round-trips)."); + + // Unrelated re-save: client submits the served tick unchanged. + var model = new TimePlanningPlanningPrDayModel { Break1Shift = 7 }; + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.EqualTo(33), + "Re-save with the served shown tick is idempotent: the exact 33-min override survives (not rounded to 30)."); + } + + /// + /// RE-EDIT of an already-overridden shift still works. Same exact 33-min + /// override (shown as tick 7), but the user picks a genuinely different break + /// value (Break1Shift = 9). 9 ≠ shown tick 7 → user changed the pause → + /// the override updates to (9-1)*5 = 40 min. + /// + [Test] + public void Inference_ReEditOverriddenShift_DifferentTick_UpdatesOverride() + { + var reg = new PlanRegistration + { + Date = Date, + Pause1OverrideMinutes = 33, + Pause1Id = 6, + }; + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + var model = new TimePlanningPlanningPrDayModel { Break1Shift = 9 }; // (9-1)*5 = 40 min + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.EqualTo(40), + "Re-editing to a different break tick (9 ≠ shown 7) updates the override to (9-1)*5 = 40 min."); + } + + /// + /// FIX 2: an explicit web clear (ClearPauseOverrides) reverts an active override + /// back to null (compute-from-slots), and SKIPS inference for that shift, so the + /// pause falls back to the recorded slot sum. + /// + [Test] + public void ExplicitClear_RevertsOverrideToNull_FallsBackToSlotSum() + { + var reg = new PlanRegistration + { + Date = Date, + Pause1OverrideMinutes = 12, // an active override... + Pause1Id = 0, + // ...over recorded slots summing to 5 min (OFF grid: 19:13:26 -> 19:16:52). + Pause1StartedAt = new DateTime(2026, 6, 19, 19, 13, 26, DateTimeKind.Utc), + Pause1StoppedAt = new DateTime(2026, 6, 19, 19, 16, 52, DateTimeKind.Utc), + }; + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + // Web sends an explicit clear; Break1Shift would otherwise look "changed". + var model = new TimePlanningPlanningPrDayModel { ClearPauseOverrides = true, Break1Shift = 99 }; + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.Null, + "Explicit clear must revert the override to null."); + Assert.That(PlanRegistrationHelper.ComputeShiftPauseSeconds(reg, 1, false), + Is.EqualTo(5 * 60), "After clear, pause falls back to the recorded slot sum (5 min)."); + } + + /// + /// FIX 2: a per-shift explicit value signal (Pause{N}OverrideMinutesSpecified + /// with a value) sets that exact override and skips inference for the shift. + /// + [Test] + public void ExplicitPerShiftValue_SetsOverride_SkipsInference() + { + var reg = new PlanRegistration { Date = Date, Pause1Id = 0 }; + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + var model = new TimePlanningPlanningPrDayModel + { + Pause1OverrideMinutes = 25, + Pause1OverrideMinutesSpecified = true, + Break1Shift = 3, // would infer 10 min, but the explicit value wins + }; + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, NoneHandled()); + + Assert.That(reg.Pause1OverrideMinutes, Is.EqualTo(25), + "Explicit per-shift value wins over the Break1Shift inference."); + } + + /// + /// FIX 4: a shift whose override was already set by the flag-ON exact-minute + /// path (passed in the handled set) must NOT be overwritten by the coarse + /// Break{N}Shift inference — the exact-minute value wins. + /// + [Test] + public void ExactMinuteHandledShift_NotOverwrittenByInference() + { + var reg = new PlanRegistration { Date = Date, Pause1Id = 0 }; + // Simulate the exact-minute loop having set 33 min on shift 1. + PlanRegistrationHelper.SetShiftPauseOverrideMinutes(reg, 1, 33); + var preEditShownTicks = TimePlanningPlanningService.CaptureCurrentShiftShownTicks(reg); + var handled = new HashSet { 1 }; + var model = new TimePlanningPlanningPrDayModel { Break1Shift = 5 }; // would infer 20 min + + TimePlanningPlanningService.ApplyInferredPauseOverrides(reg, model, preEditShownTicks, handled); + + Assert.That(reg.Pause1OverrideMinutes, Is.EqualTo(33), + "Exact-minute override must survive; inference must skip the handled shift."); + } + + /// + /// FIX 3: EnsureTimestampsFromIds must NOT fabricate Pause{N}StartedAt/StoppedAt + /// for a shift carrying an active override, even when the legacy Pause{N}Id is + /// non-zero and the observation columns are empty. The override drives the + /// total; the documentation columns stay untouched (here: null). + /// + [Test] + public void EnsureTimestampsFromIds_OverrideActive_DoesNotFabricatePauseStamps() + { + var reg = new PlanRegistration + { + Date = Date, + Start1StartedAt = Date.AddHours(8), + Stop1StoppedAt = Date.AddHours(16), + Pause1Id = 4, // legacy 15-min tick that WOULD synthesize a pause pair... + Pause1OverrideMinutes = 10, // ...but an override is active. + Pause1StartedAt = null, + Pause1StoppedAt = null, + }; + + TimePlanningPlanningService.EnsureTimestampsFromIds(reg); + + Assert.Multiple(() => + { + Assert.That(reg.Pause1StartedAt, Is.Null, + "Under an active override the pause start must NOT be fabricated from Pause1Id."); + Assert.That(reg.Pause1StoppedAt, Is.Null, + "Under an active override the pause stop must NOT be fabricated from Pause1Id."); + }); + } + + /// + /// FIX 3 negative companion: with NO override, the legacy Pause{N}Id synthesis + /// still runs (existing behavior preserved). + /// + [Test] + public void EnsureTimestampsFromIds_NoOverride_StillSynthesizesFromPauseId() + { + var reg = new PlanRegistration + { + Date = Date, + Start1StartedAt = Date.AddHours(8), + Stop1StoppedAt = Date.AddHours(16), + Pause1Id = 4, // (4-1)*5 = 15 min + Pause1OverrideMinutes = null, + Pause1StartedAt = null, + Pause1StoppedAt = null, + }; + + TimePlanningPlanningService.EnsureTimestampsFromIds(reg); + + Assert.Multiple(() => + { + Assert.That(reg.Pause1StartedAt, Is.Not.Null, + "With no override the legacy synthesis still runs."); + Assert.That( + (reg.Pause1StoppedAt!.Value - reg.Pause1StartedAt!.Value).TotalMinutes, + Is.EqualTo(15), "Synthesized pause spans (Pause1Id-1)*5 = 15 min."); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs index aceff5af..13ca420d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -455,6 +455,373 @@ public async Task Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorre "Sub-slot pause stamps (Pause10*) must aggregate when UseOneMinuteIntervals = true."); } + /// + /// Phase 2 pause-override regression (16288 shape). A UseDetailedPauseEditing=false, + /// UseOneMinuteIntervals=false site has a shift-1 day with multiple recorded pause + /// sub-slots (Pause1 + Pause10 + Pause11) summing to ~30 min, plus a real Start1/Stop1 + /// 08:00-16:00 work span. The admin edits the shift-1 pause total to a DIFFERENT value + /// (Break1Shift = 3 → (3-1)*5 = 10 min). + /// + /// Pre-fix (#1626 source-of-truth-from-slots): ComputeShiftPauseSeconds sums the + /// surviving sub-slots (30 min) and ignores the admin's edit, so the reloaded + /// NettoHoursInSeconds is 28800-1800 = 27000. + /// + /// Post-fix (override): the edit sets Pause1OverrideMinutes = 10, ComputeShiftPause- + /// Seconds returns 10*60, and the reloaded NettoHoursInSeconds is 28800-600 = 28200. + /// CRITICALLY the recorded Pause10/Pause11 timestamps must STILL be present in the DB + /// (non-destructive — the worker's record is documentation). + /// + [Test] + public async Task Update_AdminEditsPauseTotal_OverridesSlotSum_NonDestructive() + { + // Arrange — common config: flag-off, simple pause editing. + var assignedSite = new AssignedSiteEntity + { + SiteId = 906, + UseOneMinuteIntervals = false, + UseDetailedPauseEditing = false, + UseOnlyPlanHours = false, + AutoBreakCalculationActive = false, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc); // Wednesday + var planning = new PlanRegistration + { + SdkSitId = 906, + Date = date, + // Real work span 08:00-16:00 (8h = 28800s). + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(16), + Start1Id = 97, // 08:00 in 5-min grid + Stop1Id = 193, // 16:00 in 5-min grid + // Three recorded pause sub-slots summing to 30 min (5-min grid). + Pause1StartedAt = date.AddHours(12).AddMinutes(30), + Pause1StoppedAt = date.AddHours(12).AddMinutes(40), + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(10), + Pause11StartedAt = date.AddHours(12).AddMinutes(10), + Pause11StoppedAt = date.AddHours(12).AddMinutes(20), + Pause1Id = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + // Sanity: pre-edit the slot sum is 30 min. + Assert.That( + PlanRegistrationHelper.ComputeShiftPauseSeconds(planning, 1, false), + Is.EqualTo(30 * 60), + "Pre-condition: the three sub-slots must sum to 30 min."); + + // Admin edits the shift-1 pause total to 10 min via Break1Shift. + // Break1Shift encodes (value-1)*5 minutes → 3 → 10 min. + var model = new TimePlanningPlanningPrDayModel + { + Id = planning.Id, + Date = date, + CommentOffice = "", + Break1Shift = 3, + // Round-trip the recorded timestamps untouched (as the dialog does). + Start1StartedAt = planning.Start1StartedAt, + Stop1StoppedAt = planning.Stop1StoppedAt, + Start1Id = 97, + Stop1Id = 193, + }; + + // Act + var result = await _service.Update(planning.Id, model); + + // Assert + Assert.That(result.Success, Is.True, result.Message); + + var reloaded = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == planning.Id); + + Assert.Multiple(() => + { + // Effective pause reflects the EDIT (10 min), not the stale slot sum (30 min). + Assert.That(reloaded.Pause1OverrideMinutes, Is.EqualTo(10), + "Admin edit must set the per-shift override to 10 min."); + Assert.That( + PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + Is.EqualTo(10 * 60), + "ComputeShiftPauseSeconds must honor the override, not sum slots."); + Assert.That(reloaded.NettoHoursInSeconds, Is.EqualTo(28800 - 600), + "Netto must subtract the 10-min override, not the 30-min slot sum."); + + // Non-destructive: every recorded pause timestamp must still be present. + Assert.That(reloaded.Pause10StartedAt, Is.EqualTo(date.AddHours(12))); + Assert.That(reloaded.Pause10StoppedAt, Is.EqualTo(date.AddHours(12).AddMinutes(10))); + Assert.That(reloaded.Pause11StartedAt, Is.EqualTo(date.AddHours(12).AddMinutes(10))); + Assert.That(reloaded.Pause11StoppedAt, Is.EqualTo(date.AddHours(12).AddMinutes(20))); + Assert.That(reloaded.Pause1StartedAt, Is.EqualTo(date.AddHours(12).AddMinutes(30))); + Assert.That(reloaded.Pause1StoppedAt, Is.EqualTo(date.AddHours(12).AddMinutes(40))); + }); + } + + /// + /// Change-detection guard (FIX 1 — the 16288-shape unit-mismatch bug). A + /// punch-clock row's recorded slots sum to 30 min while its legacy Pause1Id is + /// 0 (sub-slots only). The dialog round-trips Break1Shift = Pause1Id = 0 (the + /// value the READ path emits) and only nudges the stop time later (16:00 → + /// 17:00). Because the submitted Break1Shift equals the PRE-EDIT Pause1Id, + /// change-detection must see "pause unchanged" and NOT lock an override — the + /// row keeps computing pause from its recorded slots. + /// + /// Pre-fix the baseline compared the coarse tick against the exact slot-sum + /// (30 min); since 0-tick ≠ 30 it ALWAYS spuriously locked an override on every + /// save. The fix compares Break1Shift against the pre-edit Pause1Id instead. + /// + [Test] + public async Task Update_EditOnlyStartStop_DoesNotSetOverride() + { + var assignedSite = new AssignedSiteEntity + { + SiteId = 907, + UseOneMinuteIntervals = false, + UseDetailedPauseEditing = false, + UseOnlyPlanHours = false, + AutoBreakCalculationActive = false, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc); + var planning = new PlanRegistration + { + SdkSitId = 907, + Date = date, + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(16), + Start1Id = 97, + Stop1Id = 193, + // 30 min of recorded pause across three slots (5-min grid). + Pause1StartedAt = date.AddHours(12).AddMinutes(30), + Pause1StoppedAt = date.AddHours(12).AddMinutes(40), + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(10), + Pause11StartedAt = date.AddHours(12).AddMinutes(10), + Pause11StoppedAt = date.AddHours(12).AddMinutes(20), + Pause1Id = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + // The dialog round-trips Break1Shift = pre-edit Pause1Id (0) — the value + // the READ path emits for this no-override row — while only nudging the + // stop time later (16:00 → 17:00). + var model = new TimePlanningPlanningPrDayModel + { + Id = planning.Id, + Date = date, + CommentOffice = "", + Break1Shift = 0, // == pre-edit Pause1Id (0) → pause unchanged + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(17), + Start1Id = 97, + Stop1Id = 205, + }; + + var result = await _service.Update(planning.Id, model); + Assert.That(result.Success, Is.True, result.Message); + + var reloaded = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == planning.Id); + + Assert.That(reloaded.Pause1OverrideMinutes, Is.Null, + "Unchanged pause total must not lock an override."); + Assert.That( + PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + Is.EqualTo(30 * 60), + "Pause still computed from the recorded slots (30 min)."); + } + + /// + /// Setting the pause to empty (Break1Shift = 0) on a row whose pre-edit + /// Pause1Id was non-zero is a genuine CHANGE (0 ≠ pre-edit tick), so the + /// inference sets the override to 0 (explicit empty pause) WITHOUT deleting the + /// recorded sub-slot timestamps. (When the pre-edit Pause1Id is already 0, + /// Break1Shift = 0 is indistinguishable from "unchanged" and the web uses the + /// explicit clear signal instead — see Update_ExplicitClear_RevertsToNull.) + /// + [Test] + public async Task Update_ClearPause_SetsZeroOverride_NonDestructive() + { + var assignedSite = new AssignedSiteEntity + { + SiteId = 908, + UseOneMinuteIntervals = false, + UseDetailedPauseEditing = false, + UseOnlyPlanHours = false, + AutoBreakCalculationActive = false, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc); + var planning = new PlanRegistration + { + SdkSitId = 908, + Date = date, + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(16), + Start1Id = 97, + Stop1Id = 193, + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(10), + Pause11StartedAt = date.AddHours(12).AddMinutes(10), + Pause11StoppedAt = date.AddHours(12).AddMinutes(20), + Pause1Id = 3, // pre-edit coarse tick (= 10 min); read path emits Break1Shift = 3 + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + var model = new TimePlanningPlanningPrDayModel + { + Id = planning.Id, + Date = date, + CommentOffice = "", + Break1Shift = 0, // cleared (0 ≠ pre-edit 3) → empty pause → override 0 + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(16), + Start1Id = 97, + Stop1Id = 193, + }; + + var result = await _service.Update(planning.Id, model); + Assert.That(result.Success, Is.True, result.Message); + + var reloaded = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == planning.Id); + + Assert.Multiple(() => + { + Assert.That(reloaded.Pause1OverrideMinutes, Is.EqualTo(0), + "Clearing the pause sets an explicit zero override."); + Assert.That( + PlanRegistrationHelper.ComputeShiftPauseSeconds(reloaded, 1, false), + Is.EqualTo(0), + "Effective pause is zero."); + Assert.That(reloaded.NettoHoursInSeconds, Is.EqualTo(28800), + "Full 8h with no pause deducted."); + // Non-destructive: recorded timestamps intact. + Assert.That(reloaded.Pause10StartedAt, Is.EqualTo(date.AddHours(12))); + Assert.That(reloaded.Pause11StoppedAt, Is.EqualTo(date.AddHours(12).AddMinutes(20))); + }); + } + + /// + /// Read-projection seam (history path): a row carrying a pause override is + /// projected onto the outgoing per-day DTO as a single synthesized pause pair + /// (Pause1StartedAt..StoppedAt summing to the override, anchored at the shift + /// start), with the sub-slot DTO fields emptied — while the real DB row keeps + /// every recorded timestamp. + /// + [Test] + public async Task Index_OverrideSet_DtoCarriesSynthesizedPause_SubSlotsEmpty_DbUnchanged() + { + var assignedSite = new AssignedSiteEntity + { + SiteId = 909, + UseOneMinuteIntervals = false, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 5, 14, 0, 0, 0, DateTimeKind.Utc); + var planning = new PlanRegistration + { + SdkSitId = 909, + Date = date, + Start1StartedAt = date.AddHours(8), + Stop1StoppedAt = date.AddHours(16), + Start1Id = 97, + // Recorded sub-slots (30 min) that must survive in the DB... + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(10), + Pause11StartedAt = date.AddHours(12).AddMinutes(10), + Pause11StoppedAt = date.AddHours(12).AddMinutes(20), + Pause1StartedAt = date.AddHours(12).AddMinutes(30), + Pause1StoppedAt = date.AddHours(12).AddMinutes(40), + // ...but the override says 15 min. + Pause1OverrideMinutes = 15, + Pause1Id = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + var planningsInPeriod = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .Where(x => x.SdkSitId == 909) + .Select(x => new PlanRegistration { Id = x.Id, Date = x.Date }) + .ToListAsync(); + + var siteModel = new TimePlanningPlanningModel + { + SiteId = 909, + SiteName = "Test site 909", + UseOneMinuteIntervals = false, + PlanningPrDayModels = new List() + }; + var sdkSite = new SdkSite { Name = "Test site 909", MicrotingUid = 909 }; + + var result = await PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod( + planningsInPeriod, + siteModel, + TimePlanningPnDbContext, + assignedSite, + Substitute.For>(), + sdkSite, + date.AddDays(-1), + date.AddDays(1), + _options); + + var prDay = result.PlanningPrDayModels.Single(x => x.Date.Date == date.Date); + + Assert.Multiple(() => + { + // DTO: synthesized single pause pair anchored at shift start, 15 min long. + Assert.That(prDay.Pause1OverrideMinutes, Is.EqualTo(15)); + Assert.That(prDay.Pause1StartedAt, Is.EqualTo(date.AddHours(8))); + Assert.That(prDay.Pause1StoppedAt, Is.EqualTo(date.AddHours(8).AddMinutes(15))); + // DTO sub-slots emptied so the app's timestamp sum equals the override. + Assert.That(prDay.Pause10StartedAt, Is.Null); + Assert.That(prDay.Pause11StartedAt, Is.Null); + // PauseMinutes aggregate equals the override (15 min). + Assert.That(prDay.PauseMinutes, Is.EqualTo(15.0)); + // FIX 1b: legacy coarse fields reflect the override's tick so a re-save + // round-trips stably (Break1Shift = (15/5)+1 = 4; DTO Pause1Id = tick-1 = 3). + Assert.That(prDay.Break1Shift, Is.EqualTo(4), + "Break1Shift must reflect the override's coarse tick for stable round-trip."); + Assert.That(prDay.Pause1Id, Is.EqualTo(3), + "DTO Pause1Id must be the override tick minus 1 (existing read convention)."); + }); + + // DB row untouched — documentation preserved. + var dbRow = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == planning.Id); + Assert.Multiple(() => + { + Assert.That(dbRow.Pause10StartedAt, Is.EqualTo(date.AddHours(12))); + Assert.That(dbRow.Pause10StoppedAt, Is.EqualTo(date.AddHours(12).AddMinutes(10))); + Assert.That(dbRow.Pause11StartedAt, Is.EqualTo(date.AddHours(12).AddMinutes(10))); + Assert.That(dbRow.Pause1OverrideMinutes, Is.EqualTo(15)); + }); + } + /// /// Negative companion to Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly: /// when UseOneMinuteIntervals = false the same row's PauseMinutes must be 0, diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_eform-angular-time-planning-plugin.sql b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_eform-angular-time-planning-plugin.sql index ecd44213..e1f8015a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_eform-angular-time-planning-plugin.sql +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_eform-angular-time-planning-plugin.sql @@ -959,6 +959,11 @@ CREATE TABLE `PlanRegistrationVersions` ( `Pause5Id` int(11) NOT NULL DEFAULT 0, `Pause5StartedAt` datetime(6) DEFAULT NULL, `Pause5StoppedAt` datetime(6) DEFAULT NULL, + `Pause1OverrideMinutes` int(11) DEFAULT NULL, + `Pause2OverrideMinutes` int(11) DEFAULT NULL, + `Pause3OverrideMinutes` int(11) DEFAULT NULL, + `Pause4OverrideMinutes` int(11) DEFAULT NULL, + `Pause5OverrideMinutes` int(11) DEFAULT NULL, `Start3Id` int(11) NOT NULL DEFAULT 0, `Start3StartedAt` datetime(6) DEFAULT NULL, `Start4Id` int(11) NOT NULL DEFAULT 0, @@ -13966,6 +13971,11 @@ CREATE TABLE `PlanRegistrations` ( `Pause5Id` int(11) NOT NULL DEFAULT 0, `Pause5StartedAt` datetime(6) DEFAULT NULL, `Pause5StoppedAt` datetime(6) DEFAULT NULL, + `Pause1OverrideMinutes` int(11) DEFAULT NULL, + `Pause2OverrideMinutes` int(11) DEFAULT NULL, + `Pause3OverrideMinutes` int(11) DEFAULT NULL, + `Pause4OverrideMinutes` int(11) DEFAULT NULL, + `Pause5OverrideMinutes` int(11) DEFAULT NULL, `Start3Id` int(11) NOT NULL DEFAULT 0, `Start3StartedAt` datetime(6) DEFAULT NULL, `Start4Id` int(11) NOT NULL DEFAULT 0, 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 ff72ee5b..a6d37e40 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -1262,6 +1262,14 @@ await dbContext.PlanRegistrations.AsNoTracking() planningModel.NettoHoursOverride = planRegistration.NettoHoursOverride; planningModel.NettoHoursOverrideActive = planRegistration.NettoHoursOverrideActive; + // Approach C READ projection: for any shift with a pause override, + // present a single synthesized pause pair (sum = override) and empty + // the sub-slots IN THE RESPONSE DTO, so the unchanged mobile app's + // timestamp-summing display reflects the override. The DB row + // (planRegistration) is read-only here — documentation preserved. + // PauseMinutes above is already override-aware via AggregatePauseMinutes. + ProjectPauseOverridesOntoDto(planningModel, planRegistration); + planningsInPeriod = await dbContext.PlanRegistrations .AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) @@ -1969,6 +1977,12 @@ public static async Task ReadBySiteAndDate( Shift2PauseNumber = planRegistration.Shift2PauseNumber }; + // Approach C READ projection (today path): when a shift carries a pause + // override, present a single synthesized pause pair and empty the + // sub-slots IN THE RESPONSE MODEL so the unchanged app's timestamp-summing + // display reflects the override. The DB row is read-only here. + ProjectPauseOverridesOntoWorkingHours(timePlanningWorkingHoursModel, planRegistration); + return timePlanningWorkingHoursModel; // return new OperationDataResult(true, "Plan registration found", // timePlanningWorkingHoursModel); @@ -2133,6 +2147,16 @@ private static DateTime FloorTo5Min(DateTime dt) /// public static int ComputeShiftPauseSeconds(PlanRegistration r, int shift, bool useOneMinuteIntervals) { + // Admin/manual pause override takes precedence: when set, it is the + // authoritative total pause MINUTES for the shift. The recorded + // Pause{N}StartedAt/StoppedAt sub-slots are preserved untouched in the DB + // (documentation of what the worker actually did) but are not summed here. + var overrideMinutes = GetShiftPauseOverrideMinutes(r, shift); + if (overrideMinutes.HasValue) + { + return overrideMinutes.Value * 60; + } + long totalSeconds = 0; var hasTimestampedSlot = false; @@ -2177,6 +2201,295 @@ public static int ComputeShiftPauseSeconds(PlanRegistration r, int shift, bool u return (int)totalSeconds; } + /// + /// Read the per-shift admin/manual pause override (in minutes) from the + /// registration. null = no override (compute pause from recorded slots); + /// non-null = authoritative total pause minutes for that shift. + /// + public static int? GetShiftPauseOverrideMinutes(PlanRegistration r, int shift) + { + return shift switch + { + 1 => r.Pause1OverrideMinutes, + 2 => r.Pause2OverrideMinutes, + 3 => r.Pause3OverrideMinutes, + 4 => r.Pause4OverrideMinutes, + 5 => r.Pause5OverrideMinutes, + _ => null + }; + } + + /// + /// Set the per-shift admin/manual pause override (in minutes) on the + /// registration. null reverts to compute-from-slots. + /// + public static void SetShiftPauseOverrideMinutes(PlanRegistration r, int shift, int? minutes) + { + switch (shift) + { + case 1: r.Pause1OverrideMinutes = minutes; break; + case 2: r.Pause2OverrideMinutes = minutes; break; + case 3: r.Pause3OverrideMinutes = minutes; break; + case 4: r.Pause4OverrideMinutes = minutes; break; + case 5: r.Pause5OverrideMinutes = minutes; break; + } + } + + /// + /// READ projection (Approach C): when a shift carries a pause override, + /// rewrite the OUTGOING DTO so the (unchanged) mobile app, which displays the + /// pause by summing the served Pause{N}StartedAt/StoppedAt timestamps, shows + /// the override duration. For each overridden shift the DTO gets a single + /// synthesized pause pair (anchored at the shift start, or midnight + the + /// 5-min-grid Start{N}Id when no start timestamp exists) and ALL of that + /// shift's sub-slot pause timestamps are emptied. The override minutes are + /// also surfaced on the DTO for the web dialog. + /// + /// CRITICAL: this mutates the response DTO only. The DB entity + /// () is read-only here and never written. + /// + public static void ProjectPauseOverridesOntoDto( + TimePlanningPlanningPrDayModel model, + PlanRegistration source) + { + for (var shift = 1; shift <= 5; shift++) + { + var overrideMinutes = GetShiftPauseOverrideMinutes(source, shift); + // Surface the raw override on the DTO regardless (web dialog read). + SetDtoPauseOverrideMinutes(model, shift, overrideMinutes); + + if (!overrideMinutes.HasValue) + { + continue; + } + + var (pauseStart, pauseStop) = ComputeOverridePausePair(source, shift, overrideMinutes.Value); + + EmptyShiftPauseStampsOnDto(model, shift); + SetDtoPrimaryPause(model, shift, pauseStart, pauseStop); + + // Edit round-trip consistency (FIX 1b): reflect the override in the + // legacy coarse fields the client round-trips so a re-save without a + // pause change does NOT re-trigger the write-path inference. The read + // path mirrors the entity as Break{N}Shift = Pause{N}Id (raw coarse + // tick) and the DTO's Pause{N}Id = tick - 1; the write change-detection + // compares model.Break{N}Shift against the pre-edit entity Pause{N}Id. + // So the displayed/edited break tick must equal the override's coarse + // tick: (overrideMinutes / 5) + 1. + var coarseTick = (overrideMinutes.Value / 5) + 1; + SetDtoBreakShift(model, shift, coarseTick); + SetDtoPauseId(model, shift, coarseTick > 0 ? coarseTick - 1 : 0); + } + } + + /// + /// READ projection for the TODAY (working-hours) path — same Approach C + /// contract as the history overload, but onto the + /// the gRPC ReadWorkingHours + /// response is mapped from. DB entity () is + /// read-only. + /// + public static void ProjectPauseOverridesOntoWorkingHours( + TimePlanningWorkingHoursModel model, + PlanRegistration source) + { + for (var shift = 1; shift <= 5; shift++) + { + var overrideMinutes = GetShiftPauseOverrideMinutes(source, shift); + if (!overrideMinutes.HasValue) + { + continue; + } + + var (pauseStart, pauseStop) = ComputeOverridePausePair(source, shift, overrideMinutes.Value); + + EmptyShiftPauseStampsOnWorkingHours(model, shift); + SetWorkingHoursPrimaryPause(model, shift, pauseStart, pauseStop); + + // Edit round-trip consistency (FIX 1b): the working-hours model has no + // Break{N}Shift / Pause{N}Id; its legacy coarse pause field is + // Shift{N}Pause (read path sets it from Pause{N}Id). Reflect the + // override there as the coarse tick so a re-save round-trips stably. + SetWorkingHoursShiftPause(model, shift, (overrideMinutes.Value / 5) + 1); + } + } + + /// + /// Synthesized single pause pair for an overridden shift (FIX simplifier): the + /// pause is anchored at the shift's start timestamp, or — when no start stamp + /// exists — at midnight + the 5-min-grid Start{N}Id offset (else midnight), and + /// runs for the override's whole minutes. Shared by both READ projection + /// overloads so the anchor math stays in one place. + /// + private static (DateTime Start, DateTime Stop) ComputeOverridePausePair( + PlanRegistration source, int shift, int overrideMinutes) + { + var midnight = source.Date.Date; + var (shiftStartStamp, startId) = GetShiftStartAnchor(source, shift); + var pauseStart = shiftStartStamp + ?? (startId > 0 ? midnight.AddMinutes((startId - 1) * 5) : midnight); + var pauseStop = pauseStart.AddMinutes(overrideMinutes); + return (pauseStart, pauseStop); + } + + private static void SetWorkingHoursShiftPause(TimePlanningWorkingHoursModel m, int shift, int coarseTick) + { + switch (shift) + { + case 1: m.Shift1Pause = coarseTick; break; + case 2: m.Shift2Pause = coarseTick; break; + case 3: m.Shift3Pause = coarseTick; break; + case 4: m.Shift4Pause = coarseTick; break; + case 5: m.Shift5Pause = coarseTick; break; + } + } + + private static void SetWorkingHoursPrimaryPause(TimePlanningWorkingHoursModel m, int shift, DateTime start, DateTime stop) + { + switch (shift) + { + case 1: m.Pause1StartedAt = start; m.Pause1StoppedAt = stop; break; + case 2: m.Pause2StartedAt = start; m.Pause2StoppedAt = stop; break; + case 3: m.Pause3StartedAt = start; m.Pause3StoppedAt = stop; break; + case 4: m.Pause4StartedAt = start; m.Pause4StoppedAt = stop; break; + case 5: m.Pause5StartedAt = start; m.Pause5StoppedAt = stop; break; + } + } + + private static void EmptyShiftPauseStampsOnWorkingHours(TimePlanningWorkingHoursModel m, int shift) + { + switch (shift) + { + case 1: + m.Pause10StartedAt = null; m.Pause10StoppedAt = null; + m.Pause11StartedAt = null; m.Pause11StoppedAt = null; + m.Pause12StartedAt = null; m.Pause12StoppedAt = null; + m.Pause13StartedAt = null; m.Pause13StoppedAt = null; + m.Pause14StartedAt = null; m.Pause14StoppedAt = null; + m.Pause15StartedAt = null; m.Pause15StoppedAt = null; + m.Pause16StartedAt = null; m.Pause16StoppedAt = null; + m.Pause17StartedAt = null; m.Pause17StoppedAt = null; + m.Pause18StartedAt = null; m.Pause18StoppedAt = null; + m.Pause19StartedAt = null; m.Pause19StoppedAt = null; + m.Pause100StartedAt = null; m.Pause100StoppedAt = null; + m.Pause101StartedAt = null; m.Pause101StoppedAt = null; + m.Pause102StartedAt = null; m.Pause102StoppedAt = null; + break; + case 2: + m.Pause20StartedAt = null; m.Pause20StoppedAt = null; + m.Pause21StartedAt = null; m.Pause21StoppedAt = null; + m.Pause22StartedAt = null; m.Pause22StoppedAt = null; + m.Pause23StartedAt = null; m.Pause23StoppedAt = null; + m.Pause24StartedAt = null; m.Pause24StoppedAt = null; + m.Pause25StartedAt = null; m.Pause25StoppedAt = null; + m.Pause26StartedAt = null; m.Pause26StoppedAt = null; + m.Pause27StartedAt = null; m.Pause27StoppedAt = null; + m.Pause28StartedAt = null; m.Pause28StoppedAt = null; + m.Pause29StartedAt = null; m.Pause29StoppedAt = null; + m.Pause200StartedAt = null; m.Pause200StoppedAt = null; + m.Pause201StartedAt = null; m.Pause201StoppedAt = null; + m.Pause202StartedAt = null; m.Pause202StoppedAt = null; + break; + } + } + + private static (DateTime? Stamp, int StartId) GetShiftStartAnchor(PlanRegistration r, int shift) => shift switch + { + 1 => (r.Start1StartedAt, r.Start1Id), + 2 => (r.Start2StartedAt, r.Start2Id), + 3 => (r.Start3StartedAt, r.Start3Id), + 4 => (r.Start4StartedAt, r.Start4Id), + 5 => (r.Start5StartedAt, r.Start5Id), + _ => (null, 0) + }; + + private static void SetDtoPauseOverrideMinutes(TimePlanningPlanningPrDayModel m, int shift, int? minutes) + { + switch (shift) + { + case 1: m.Pause1OverrideMinutes = minutes; break; + case 2: m.Pause2OverrideMinutes = minutes; break; + case 3: m.Pause3OverrideMinutes = minutes; break; + case 4: m.Pause4OverrideMinutes = minutes; break; + case 5: m.Pause5OverrideMinutes = minutes; break; + } + } + + private static void SetDtoBreakShift(TimePlanningPlanningPrDayModel m, int shift, int coarseTick) + { + switch (shift) + { + case 1: m.Break1Shift = coarseTick; break; + case 2: m.Break2Shift = coarseTick; break; + case 3: m.Break3Shift = coarseTick; break; + case 4: m.Break4Shift = coarseTick; break; + case 5: m.Break5Shift = coarseTick; break; + } + } + + private static void SetDtoPauseId(TimePlanningPlanningPrDayModel m, int shift, int pauseId) + { + switch (shift) + { + case 1: m.Pause1Id = pauseId; break; + case 2: m.Pause2Id = pauseId; break; + case 3: m.Pause3Id = pauseId; break; + case 4: m.Pause4Id = pauseId; break; + case 5: m.Pause5Id = pauseId; break; + } + } + + private static void SetDtoPrimaryPause(TimePlanningPlanningPrDayModel m, int shift, DateTime start, DateTime stop) + { + switch (shift) + { + case 1: m.Pause1StartedAt = start; m.Pause1StoppedAt = stop; break; + case 2: m.Pause2StartedAt = start; m.Pause2StoppedAt = stop; break; + case 3: m.Pause3StartedAt = start; m.Pause3StoppedAt = stop; break; + case 4: m.Pause4StartedAt = start; m.Pause4StoppedAt = stop; break; + case 5: m.Pause5StartedAt = start; m.Pause5StoppedAt = stop; break; + } + } + + private static void EmptyShiftPauseStampsOnDto(TimePlanningPlanningPrDayModel m, int shift) + { + switch (shift) + { + case 1: + m.Pause10StartedAt = null; m.Pause10StoppedAt = null; + m.Pause11StartedAt = null; m.Pause11StoppedAt = null; + m.Pause12StartedAt = null; m.Pause12StoppedAt = null; + m.Pause13StartedAt = null; m.Pause13StoppedAt = null; + m.Pause14StartedAt = null; m.Pause14StoppedAt = null; + m.Pause15StartedAt = null; m.Pause15StoppedAt = null; + m.Pause16StartedAt = null; m.Pause16StoppedAt = null; + m.Pause17StartedAt = null; m.Pause17StoppedAt = null; + m.Pause18StartedAt = null; m.Pause18StoppedAt = null; + m.Pause19StartedAt = null; m.Pause19StoppedAt = null; + m.Pause100StartedAt = null; m.Pause100StoppedAt = null; + m.Pause101StartedAt = null; m.Pause101StoppedAt = null; + m.Pause102StartedAt = null; m.Pause102StoppedAt = null; + break; + case 2: + m.Pause20StartedAt = null; m.Pause20StoppedAt = null; + m.Pause21StartedAt = null; m.Pause21StoppedAt = null; + m.Pause22StartedAt = null; m.Pause22StoppedAt = null; + m.Pause23StartedAt = null; m.Pause23StoppedAt = null; + m.Pause24StartedAt = null; m.Pause24StoppedAt = null; + m.Pause25StartedAt = null; m.Pause25StoppedAt = null; + m.Pause26StartedAt = null; m.Pause26StoppedAt = null; + m.Pause27StartedAt = null; m.Pause27StoppedAt = null; + m.Pause28StartedAt = null; m.Pause28StoppedAt = null; + m.Pause29StartedAt = null; m.Pause29StoppedAt = null; + m.Pause200StartedAt = null; m.Pause200StoppedAt = null; + m.Pause201StartedAt = null; m.Pause201StoppedAt = null; + m.Pause202StartedAt = null; m.Pause202StoppedAt = null; + break; + // Shifts 3-5 have no sub-slots; the primary pause pair is rewritten + // by SetDtoPrimaryPause. + } + } + /// /// Extract pause intervals from PlanRegistration. /// Consumes and filters out incomplete diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs index 54039e8a..f4bbd34f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs @@ -129,6 +129,36 @@ public class TimePlanningPlanningPrDayModel public int Break3Shift { get; set; } public int Break4Shift { get; set; } public int Break5Shift { get; set; } + + // Admin/manual pause override (Approach C). null = compute pause from the + // recorded slots (current behavior); non-null = authoritative total pause + // MINUTES for that shift. The web workday dialog sets these explicitly; on + // read they are populated from the entity so the dialog can show the value. + // The worker's recorded Pause*StartedAt/StoppedAt are never destroyed. + public int? Pause1OverrideMinutes { get; set; } + public int? Pause2OverrideMinutes { get; set; } + public int? Pause3OverrideMinutes { get; set; } + public int? Pause4OverrideMinutes { get; set; } + public int? Pause5OverrideMinutes { get; set; } + + // Clear-to-null capability (FIX 2 — plumbing for the Phase 3 web affordance). + // The override fields above are int? so "not sent" and "explicit null/clear" + // are indistinguishable on the wire. These companion signals let the web path + // explicitly distinguish CLEAR (revert to compute-from-slots) from NOT-SENT + // (leave the inference / existing override untouched): + // • Pause{N}OverrideMinutesSpecified == true → honor Pause{N}OverrideMinutes + // for that shift exactly (a value sets it; null clears to compute-from-slots) + // and SKIP inference for that shift. + // • ClearPauseOverrides == true → clear ALL five shifts to null in one shot + // (coarse convenience signal; takes precedence over per-shift signals). + // The app/inference path does NOT need to clear — Break{N}Shift == 0 → override + // 0 is sufficient there. The Phase 3 web UI wires the clear affordance to these. + public bool Pause1OverrideMinutesSpecified { get; set; } + public bool Pause2OverrideMinutesSpecified { get; set; } + public bool Pause3OverrideMinutesSpecified { get; set; } + public bool Pause4OverrideMinutesSpecified { get; set; } + public bool Pause5OverrideMinutesSpecified { get; set; } + public bool ClearPauseOverrides { get; set; } public string CommentOffice { get; set; } public string WorkerComment { get; set; } public double SumFlexStart { get; set; } 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 6bf4dc9d..6e70171d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -643,6 +643,17 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel localizationService.GetString("PlanningCannotBeEditedForResignedSite")); } + // Snapshot each shift's PRE-EDIT legacy coarse pause tick (Pause{N}Id) + // BEFORE the model is applied, so the pause-override inference can + // change-detect an admin pause edit (Approach C) by comparing the + // submitted Break{N}Shift against this tick — without locking an + // override when only start/stop changed (16288-shape rows), and + // idempotently for non-5-multiple overrides on unrelated re-saves. + var preEditShownTicks = CaptureCurrentShiftShownTicks(planning); + // Shifts whose override was set by the flag-ON exact-minute path; the + // inference must not overwrite them (FIX 4 — exact-minute wins). + var exactHandledShifts = new HashSet(); + planning.PlannedStartOfShift1 = model.PlannedStartOfShift1; planning.PlannedBreakOfShift1 = model.PlannedBreakOfShift1; planning.PlannedEndOfShift1 = model.PlannedEndOfShift1; @@ -874,7 +885,16 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel { if (minutes.HasValue) { - ApplyExactMinutePause(planning, shift, minutes.Value); + // Approach C: an exact-minute pause edit on a one-minute + // site sets the per-shift override instead of clearing + + // synthesizing the recorded pause timestamps. The worker's + // recorded Pause*StartedAt/StoppedAt are preserved as + // documentation; the override is the authoritative total. + PlanRegistrationHelper.SetShiftPauseOverrideMinutes(planning, shift, minutes.Value); + // FIX 4: mark this shift handled so the later inference + // (ApplyInferredPauseOverrides) does not overwrite the + // exact-minute value with a coarse Break{N}Shift-derived one. + exactHandledShifts.Add(shift); } } @@ -952,6 +972,13 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel planning.PlanHours = model.PlanHours; } + // Approach C: infer the per-shift pause override from the submitted + // Break{N}Shift (change-detected against the pre-edit Pause{N}Id) or + // honor an explicit web clear/value signal. Exact-minute shifts already + // set above are skipped (FIX 4). Never touches the recorded + // Pause*StartedAt/StoppedAt timestamps. + ApplyInferredPauseOverrides(planning, model, preEditShownTicks, exactHandledShifts); + double nettoMinutes = ComputePlanningNettoMinutes(planning, assignedSite.UseOneMinuteIntervals); double hours = nettoMinutes / 60; @@ -1173,6 +1200,17 @@ public async Task UpdateByCurrentUserNam( localizationService.GetString("PlanningNotFound")); } + // Snapshot each shift's PRE-EDIT EFFECTIVE SHOWN coarse tick (override → + // (override/5)+1, else Pause{N}Id) BEFORE the model is applied, so the + // pause-override inference can change-detect a manual pause edit + // (Approach C) by comparing the submitted Break{N}Shift against this + // tick — without locking an override when only start/stop changed + // (16288-shape rows), and idempotently for non-5-multiple overrides. + var preEditShownTicks = CaptureCurrentShiftShownTicks(planning); + // UpdateByCurrentUserNam has no flag-ON exact-minute pause loop, so no + // shift is pre-handled; pass an empty set for the FIX 4 contract. + var exactHandledShifts = new HashSet(); + planning.PlannedStartOfShift1 = model.PlannedStartOfShift1; planning.PlannedBreakOfShift1 = model.PlannedBreakOfShift1; planning.PlannedEndOfShift1 = model.PlannedEndOfShift1; @@ -1312,6 +1350,12 @@ public async Task UpdateByCurrentUserNam( planning = PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(assignedSite, planning); + // Approach C: infer the per-shift pause override from the submitted + // Break{N}Shift (change-detected against the pre-edit Pause{N}Id) or + // honor an explicit web clear/value signal. Never touches the recorded + // Pause*StartedAt/StoppedAt timestamps. + ApplyInferredPauseOverrides(planning, model, preEditShownTicks, exactHandledShifts); + double nettoMinutes = ComputePlanningNettoMinutes(planning, assignedSite.UseOneMinuteIntervals); double hours = nettoMinutes / 60; @@ -1449,7 +1493,7 @@ await dbContext.PlanRegistrations.AsNoTracking() /// This allows ComputeTimeTrackingFields to work with both ID-based and timestamp-based data. /// IDs use 5-minute intervals, so timestamps are rounded to nearest 5 minutes. /// - private void EnsureTimestampsFromIds(PlanRegistration planning) + internal static void EnsureTimestampsFromIds(PlanRegistration planning) { var midnight = new DateTime(planning.Date.Year, planning.Date.Month, planning.Date.Day, 0, 0, 0); @@ -1499,8 +1543,13 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) planning.Stop5StoppedAt = midnight.AddMinutes((planning.Stop5Id - 1) * 5); } - // Convert Pause IDs to timestamps if timestamps are missing - if (planning.Pause1StartedAt == null && planning.Pause1StoppedAt == null && planning.Pause1Id > 0) + // Convert Pause IDs to timestamps if timestamps are missing. + // FIX 3: never fabricate documentation timestamps for a shift carrying a + // pause override — the override (not the stale Pause{N}Id) drives the + // total, and the observation columns must reflect only real recorded + // pauses, not a synthesized pair derived from the coarse legacy field. + if (planning.Pause1OverrideMinutes == null + && planning.Pause1StartedAt == null && planning.Pause1StoppedAt == null && planning.Pause1Id > 0) { // Assume pause starts after start and lasts for the specified duration // We'll place it at the midpoint of the shift for simplicity @@ -1514,7 +1563,8 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) } } - if (planning.Pause2StartedAt == null && planning.Pause2StoppedAt == null && planning.Pause2Id > 0) + if (planning.Pause2OverrideMinutes == null + && planning.Pause2StartedAt == null && planning.Pause2StoppedAt == null && planning.Pause2Id > 0) { var pauseDurationMinutes = (planning.Pause2Id - 1) * 5; if (planning.Start2StartedAt != null && planning.Stop2StoppedAt != null) @@ -1526,7 +1576,8 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) } } - if (planning.Pause3StartedAt == null && planning.Pause3StoppedAt == null && planning.Pause3Id > 0) + if (planning.Pause3OverrideMinutes == null + && planning.Pause3StartedAt == null && planning.Pause3StoppedAt == null && planning.Pause3Id > 0) { var pauseDurationMinutes = (planning.Pause3Id - 1) * 5; if (planning.Start3StartedAt != null && planning.Stop3StoppedAt != null) @@ -1538,7 +1589,8 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) } } - if (planning.Pause4StartedAt == null && planning.Pause4StoppedAt == null && planning.Pause4Id > 0) + if (planning.Pause4OverrideMinutes == null + && planning.Pause4StartedAt == null && planning.Pause4StoppedAt == null && planning.Pause4Id > 0) { var pauseDurationMinutes = (planning.Pause4Id - 1) * 5; if (planning.Start4StartedAt != null && planning.Stop4StoppedAt != null) @@ -1550,7 +1602,8 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) } } - if (planning.Pause5StartedAt == null && planning.Pause5StoppedAt == null && planning.Pause5Id > 0) + if (planning.Pause5OverrideMinutes == null + && planning.Pause5StartedAt == null && planning.Pause5StoppedAt == null && planning.Pause5Id > 0) { var pauseDurationMinutes = (planning.Pause5Id - 1) * 5; if (planning.Start5StartedAt != null && planning.Stop5StoppedAt != null) @@ -1563,79 +1616,6 @@ private void EnsureTimestampsFromIds(PlanRegistration planning) } } - /// - /// Translates an admin-edited exact-minute pause duration for the given shift - /// into Pause*StartedAt/Pause*StoppedAt timestamps on the entity. Anchors to - /// the existing Pause*StartedAt when present (preserves the worker's actual - /// pause start) and falls back to the shift midpoint when no anchor exists. - /// Sub-slot pauses (pause10..pause19, pause100..pause102 for shift 1; - /// pause20..pause29, pause200..pause202 for shift 2) are cleared so - /// AggregatePauseMinutes does not double-count. - /// - private void ApplyExactMinutePause(PlanRegistration planning, int shift, int exactMinutes) - { - if (exactMinutes == 0) - { - ClearPauseTimestamps(planning, shift); - return; - } - - DateTime? existingStart = shift switch - { - 1 => planning.Pause1StartedAt, - 2 => planning.Pause2StartedAt, - 3 => planning.Pause3StartedAt, - 4 => planning.Pause4StartedAt, - 5 => planning.Pause5StartedAt, - _ => null, - }; - - DateTime startedAt; - if (existingStart.HasValue) - { - startedAt = existingStart.Value; - } - else - { - var (shiftStart, shiftStop) = GetShiftBounds(planning, shift); - if (!shiftStart.HasValue || !shiftStop.HasValue) - { - // No anchor available — skip the write rather than fabricate one. - return; - } - startedAt = shiftStart.Value.AddMinutes( - (shiftStop.Value - shiftStart.Value).TotalMinutes / 2); - } - - var stoppedAt = startedAt.AddMinutes(exactMinutes); - - ClearPauseTimestamps(planning, shift); - - switch (shift) - { - case 1: - planning.Pause1StartedAt = startedAt; - planning.Pause1StoppedAt = stoppedAt; - break; - case 2: - planning.Pause2StartedAt = startedAt; - planning.Pause2StoppedAt = stoppedAt; - break; - case 3: - planning.Pause3StartedAt = startedAt; - planning.Pause3StoppedAt = stoppedAt; - break; - case 4: - planning.Pause4StartedAt = startedAt; - planning.Pause4StoppedAt = stoppedAt; - break; - case 5: - planning.Pause5StartedAt = startedAt; - planning.Pause5StoppedAt = stoppedAt; - break; - } - } - /// /// Writes Start{N}StartedAt as planning.Date.Date + minutes-of-day. /// Under UseOneMinuteIntervals=true this is the authoritative store @@ -1708,6 +1688,142 @@ static int TickId(DateTime? ts) => planning.Stop5Id = TickId(planning.Stop5StoppedAt); } + /// + /// Capture each shift's PRE-EDIT EFFECTIVE shown coarse tick from the persisted + /// entity BEFORE the model is applied. This MUST mirror exactly what the read + /// path () + /// emits as Break{N}Shift, because that is the value the client round-trips: + /// • override set → (Pause{N}OverrideMinutes / 5) + 1 + /// • no override → raw Pause{N}Id + /// change-detects an admin/manual + /// pause edit by comparing model.Break{N}Shift against this shown tick. Using + /// the SHOWN tick (not the raw Pause{N}Id) is what makes a re-save IDEMPOTENT + /// for a non-5-multiple override (e.g. 33 → served tick 7): an unrelated save + /// round-trips Break{N}Shift = 7 == shown tick, so inference leaves the exact + /// override untouched instead of rounding it down to (7-1)*5 = 30. It also + /// avoids spuriously locking an override when only start/stop changed on a row + /// whose slot-sum is not a 5-minute multiple (16288 shape). + /// + internal static int[] CaptureCurrentShiftShownTicks(PlanRegistration planning) + { + var ticks = new int[6]; // index 0 unused; shifts are 1..5 + for (var shift = 1; shift <= 5; shift++) + { + var overrideMinutes = PlanRegistrationHelper.GetShiftPauseOverrideMinutes(planning, shift); + ticks[shift] = overrideMinutes.HasValue + ? (overrideMinutes.Value / 5) + 1 + : GetShiftPauseId(planning, shift); + } + return ticks; + } + + private static int GetShiftPauseId(PlanRegistration planning, int shift) => shift switch + { + 1 => planning.Pause1Id, + 2 => planning.Pause2Id, + 3 => planning.Pause3Id, + 4 => planning.Pause4Id, + 5 => planning.Pause5Id, + _ => 0 + }; + + /// + /// Change-detected, NON-destructive pause override inference (Approach C). + /// + /// Precedence per shift: + /// 1. EXPLICIT web signal (FIX 2): when the dialog marks a shift as explicitly + /// specified ( + /// or the per-shift Pause{N}OverrideMinutesSpecified flag), honor it + /// directly — a value sets the override, an explicit clear reverts to null + /// (compute-from-slots) — and SKIP inference for that shift. + /// 2. EXACT-minute path (FIX 4): a shift already handled by the flag-ON + /// exact-minute loop is in ; its override + /// must win, so inference skips it. + /// 3. INFERENCE: derive the submitted coarse tick from Break{N}Shift and + /// compare it against the PRE-EDIT EFFECTIVE SHOWN tick + /// () — the same value the read path + /// round-trips (override → (override/5)+1, else raw Pause{N}Id). Only when + /// they DIFFER does the user-changed-the-pause signal fire and set the + /// override to (Break{N}Shift - 1) * 5 minutes (Break{N}Shift == 0 → + /// override 0). When equal, the override is left UNTOUCHED so (a) editing + /// only start/stop never spuriously locks one and (b) an unrelated re-save + /// of a row with a non-5-multiple override (e.g. 33, shown as tick 7) is + /// IDEMPOTENT — the exact override survives instead of rounding to 30. A + /// genuine re-edit (Break{N}Shift differs from the shown tick) still updates + /// the override. + /// + /// This never touches any Pause*StartedAt/StoppedAt timestamps — the worker's + /// recorded pauses are preserved as documentation. + /// + internal static void ApplyInferredPauseOverrides( + PlanRegistration planning, + TimePlanningPlanningPrDayModel model, + int[] preEditShownTicks, + ISet handledShifts) + { + for (var shift = 1; shift <= 5; shift++) + { + // (1) Explicit web signal wins (set value or explicit clear → null). + if (model.ClearPauseOverrides || GetModelOverrideSpecified(model, shift)) + { + PlanRegistrationHelper.SetShiftPauseOverrideMinutes( + planning, shift, model.ClearPauseOverrides ? null : GetModelPauseOverrideMinutes(model, shift)); + continue; + } + + // (2) Exact-minute path already set this shift's override; do not clobber. + if (handledShifts.Contains(shift)) + { + continue; + } + + // (3) Inference: compare the submitted coarse tick against the pre-edit + // EFFECTIVE SHOWN tick (the same value the read path round-trips). + // Equal → the user did not touch the pause → leave the override + // as-is (idempotent: a non-5-multiple override survives a re-save). + var breakShift = GetModelBreakShift(model, shift); + if (breakShift == preEditShownTicks[shift]) + { + continue; + } + + // Changed: Break{N}Shift > 0 → (Break{N}Shift - 1) * 5 minutes; + // Break{N}Shift == 0 → empty pause → 0 minutes. + var submittedMinutes = breakShift > 0 ? (breakShift - 1) * 5 : 0; + PlanRegistrationHelper.SetShiftPauseOverrideMinutes(planning, shift, submittedMinutes); + } + } + + private static int GetModelBreakShift(TimePlanningPlanningPrDayModel model, int shift) => shift switch + { + 1 => model.Break1Shift, + 2 => model.Break2Shift, + 3 => model.Break3Shift, + 4 => model.Break4Shift, + 5 => model.Break5Shift, + _ => 0 + }; + + private static int? GetModelPauseOverrideMinutes(TimePlanningPlanningPrDayModel model, int shift) => shift switch + { + 1 => model.Pause1OverrideMinutes, + 2 => model.Pause2OverrideMinutes, + 3 => model.Pause3OverrideMinutes, + 4 => model.Pause4OverrideMinutes, + 5 => model.Pause5OverrideMinutes, + _ => null + }; + + private static bool GetModelOverrideSpecified(TimePlanningPlanningPrDayModel model, int shift) => shift switch + { + 1 => model.Pause1OverrideMinutesSpecified, + 2 => model.Pause2OverrideMinutesSpecified, + 3 => model.Pause3OverrideMinutesSpecified, + 4 => model.Pause4OverrideMinutesSpecified, + 5 => model.Pause5OverrideMinutesSpecified, + _ => false + }; + /// /// Computes total netto minutes (raw minutes; caller divides by 60 for hours) /// across all 5 shifts. Under flag-on, when both Start{N}StartedAt and @@ -1723,23 +1839,36 @@ private static double ComputePlanningNettoMinutes(PlanRegistration planning, boo { var (startedAt, stoppedAt, pauseStarted, pauseStopped, startId, stopId, pauseId) = GetShiftTimings(planning, shift); + // Admin/manual pause override wins: when set, it is the authoritative + // total pause MINUTES for the shift, replacing both the one-minute + // timestamp delta and the legacy (Pause{N}Id-1)*5 tick deduction. + var overrideMinutes = PlanRegistrationHelper.GetShiftPauseOverrideMinutes(planning, shift); + if (useOneMinuteIntervals && startedAt.HasValue && stoppedAt.HasValue && stoppedAt.Value > startedAt.Value) { var shiftMinutes = (stoppedAt.Value - startedAt.Value).TotalMinutes; - double pauseMinutes = 0; - if (pauseStarted.HasValue && pauseStopped.HasValue && pauseStopped.Value > pauseStarted.Value) + double pauseMinutes; + if (overrideMinutes.HasValue) + { + pauseMinutes = overrideMinutes.Value; + } + else if (pauseStarted.HasValue && pauseStopped.HasValue && pauseStopped.Value > pauseStarted.Value) { pauseMinutes = (pauseStopped.Value - pauseStarted.Value).TotalMinutes; } + else + { + pauseMinutes = 0; + } total += shiftMinutes - pauseMinutes; } else { if (stopId >= startId && stopId != 0) { - double sm = stopId - startId; - sm -= pauseId > 0 ? pauseId - 1 : 0; - total += sm * multiplier; + double sm = (stopId - startId) * (double)multiplier; + sm -= overrideMinutes ?? (pauseId > 0 ? (pauseId - 1) * multiplier : 0); + total += sm; } } } @@ -1757,64 +1886,6 @@ private static (DateTime? StartedAt, DateTime? StoppedAt, DateTime? PauseStarted _ => (null, null, null, null, 0, 0, 0), }; - private static void ClearPauseTimestamps(PlanRegistration planning, int shift) - { - switch (shift) - { - case 1: - planning.Pause1StartedAt = null; planning.Pause1StoppedAt = null; - planning.Pause10StartedAt = null; planning.Pause10StoppedAt = null; - planning.Pause11StartedAt = null; planning.Pause11StoppedAt = null; - planning.Pause12StartedAt = null; planning.Pause12StoppedAt = null; - planning.Pause13StartedAt = null; planning.Pause13StoppedAt = null; - planning.Pause14StartedAt = null; planning.Pause14StoppedAt = null; - planning.Pause15StartedAt = null; planning.Pause15StoppedAt = null; - planning.Pause16StartedAt = null; planning.Pause16StoppedAt = null; - planning.Pause17StartedAt = null; planning.Pause17StoppedAt = null; - planning.Pause18StartedAt = null; planning.Pause18StoppedAt = null; - planning.Pause19StartedAt = null; planning.Pause19StoppedAt = null; - planning.Pause100StartedAt = null; planning.Pause100StoppedAt = null; - planning.Pause101StartedAt = null; planning.Pause101StoppedAt = null; - planning.Pause102StartedAt = null; planning.Pause102StoppedAt = null; - break; - case 2: - planning.Pause2StartedAt = null; planning.Pause2StoppedAt = null; - planning.Pause20StartedAt = null; planning.Pause20StoppedAt = null; - planning.Pause21StartedAt = null; planning.Pause21StoppedAt = null; - planning.Pause22StartedAt = null; planning.Pause22StoppedAt = null; - planning.Pause23StartedAt = null; planning.Pause23StoppedAt = null; - planning.Pause24StartedAt = null; planning.Pause24StoppedAt = null; - planning.Pause25StartedAt = null; planning.Pause25StoppedAt = null; - planning.Pause26StartedAt = null; planning.Pause26StoppedAt = null; - planning.Pause27StartedAt = null; planning.Pause27StoppedAt = null; - planning.Pause28StartedAt = null; planning.Pause28StoppedAt = null; - planning.Pause29StartedAt = null; planning.Pause29StoppedAt = null; - planning.Pause200StartedAt = null; planning.Pause200StoppedAt = null; - planning.Pause201StartedAt = null; planning.Pause201StoppedAt = null; - planning.Pause202StartedAt = null; planning.Pause202StoppedAt = null; - break; - case 3: - planning.Pause3StartedAt = null; planning.Pause3StoppedAt = null; - break; - case 4: - planning.Pause4StartedAt = null; planning.Pause4StoppedAt = null; - break; - case 5: - planning.Pause5StartedAt = null; planning.Pause5StoppedAt = null; - break; - } - } - - private static (DateTime?, DateTime?) GetShiftBounds(PlanRegistration p, int shift) => shift switch - { - 1 => (p.Start1StartedAt, p.Stop1StoppedAt), - 2 => (p.Start2StartedAt, p.Stop2StoppedAt), - 3 => (p.Start3StartedAt, p.Stop3StoppedAt), - 4 => (p.Start4StartedAt, p.Stop4StoppedAt), - 5 => (p.Start5StartedAt, p.Stop5StoppedAt), - _ => (null, null), - }; - public async Task> GetVersionHistory(int planRegistrationId) { try diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index e1df7507..c5793eee 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj @@ -33,7 +33,7 @@ - +