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 000000000..11835ab65
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-25-pause-override-design.md
@@ -0,0 +1,83 @@
+# 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/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`)
+
+`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.
+
+**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
+
+**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).
+- 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 + 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).
+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.
+- 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.
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ComputeShiftPauseSecondsTests.cs
index 339fea710..86aa00605 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 aceff5afd..13ca420d1 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 ecd442130..e1f8015a8 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 ff72ee5b6..a6d37e403 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 54039e8ab..f4bbd34fe 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 6bf4dc9df..6e70171d6 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 e1df7507b..c5793eee7 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 @@
-
+