From 1a7561f99249331076f201e8ce740be862a0dc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 08:42:00 +0200 Subject: [PATCH 01/18] docs: design for the Reconciled ("Afstemt") day lock Marking a day Afstemt freezes it and every earlier day for that worker, against web edits, mobile registrations and background recalculation. Earlier days lock without being marked Reconciled themselves, and a day can only be unlocked once every day after it has been unlocked -- the boundary moves, it never gets a hole punched in it. Three findings shaped the design: Reconciled/ReconciledAt already exist on PlanRegistrations and PlanRegistrationVersions (migration 20260127060748) and ship in the pinned Microting.TimePlanningBase 10.0.62. Nothing writes them. So this needs no base-repo change, no migration and no NuGet bump. The lock is derived from MAX(Date) WHERE Reconciled, not stored. That makes "earlier days are locked but not marked" and "unlock only in reverse order" properties of the model rather than rules something has to enforce and keep in sync. There is no choke point: 32 write sites across 11 files in 2 repos, and PnBase.Create/Update/Delete live in the base package. But every write goes through EF change tracking, so a SaveChanges interceptor sees all of them. Call-site guards alone would repeat the MaxDaysEditable mistake, which is enforced only on the read path and in the browser and is bypassable by a crafted POST today. Barring reconcile at or after today is load-bearing beyond the obvious: it is what makes the forward flex cascades -- one running 180 days ahead, one unbounded -- provably unable to reach a locked day. The spec records that so nobody relaxes it without revisiting them. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-12-reconciled-day-lock-design.md | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md new file mode 100644 index 00000000..4a397e3c --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -0,0 +1,434 @@ +# Reconciled ("Afstemt") day lock — design + +**Date:** 2026-09-12 +**Status:** Design, awaiting review +**Scope:** `eform-angular-timeplanning-plugin` (web UI + `TimePlanning.Pn` API), +`eform-service-timeplanning-plugin` (background jobs). **No change to +`eform-timeplanning-base`.** + +--- + +## 1. Problem + +Payroll periods need to be closed. Once a period's hours are agreed, nothing +should be able to change them — not a web edit, not a mobile registration, and +not a background recalculation that quietly rewrites a flex balance three months +after the fact. + +Today the plugin has no such mechanism. It has four partial approximations, none +of which closes a day: + +| Mechanism | Scope | Enforced where | +|---|---|---| +| `MaxDaysEditable` (default 45) | tenant-wide | **read path + browser only** — a crafted POST bypasses it | +| `AllowEditOfRegistrations` / `DaysBackInTimeAllowedEditingEnabled` | per site | server-side, **mobile write path only**, boolean-only (the numeric day count is never compared) | +| `DayOfPayment` | tenant-wide | **commented out** in `PlanRegistrationHelper` (two places) | +| `TransferredToPayroll` | per registration | informational in the export preview; **blocks nothing** | + +Two per-site date watermarks exist (`FlexChainComputedThrough`, +`UseOneMinuteIntervalsFrom`) but both gate *calculation semantics*, not editing. +Notably `UseOneMinuteIntervalsFrom`'s own doc comment already refers to +"already-closed periods" — a concept that has never existed in the schema. + +## 2. Goals + +1. A web user can mark a worker's day as **Afstemt** (reconciled), recording + when it happened. +2. A reconciled day, **and every earlier day for that worker**, becomes + immutable: no edit from web or mobile, no modification by any recalculation. +3. Earlier days are locked **without** being marked `Reconciled` themselves. +4. Unlocking is possible only in strict reverse order — to free a day, every + day after it must be freed first. +5. The three resulting day states are legible in the grid without relying on + colour alone. + +## 3. Non-goals + +- Fixing the `MaxDaysEditable` server-side enforcement hole. Noted as a known + gap; it is the reason this design does not rely on call-site guards alone. +- Reconciling at any granularity other than per worker per day. +- A `ReconciledBy` column. Who performed the action is recoverable from + `PlanRegistrationVersion.UpdatedByUserId`; adding a column would require a + base-repo migration, which this design otherwise avoids entirely. +- Changing `TransferredToPayroll` semantics, or coupling reconcile to payroll + export. + +--- + +## 4. Data model — no schema change required + +`Reconciled` and `ReconciledAt` **already exist** on both `PlanRegistrations` +and `PlanRegistrationVersions`, added by migration +`20260127060748_AddReconciledAndTransferredToPayrollToPlanRegistration`: + +```csharp +public bool Reconciled { get; set; } // tinyint(1) NOT NULL DEFAULT 0 +public DateTime? ReconciledAt { get; set; } // datetime(6) NULL +``` + +They are verified present in the currently pinned package +(`Microting.TimePlanningBase` **10.0.62**). Nothing writes them today; exactly +one place reads them — the admin-only version diff in +`TimePlanningPlanningService.CompareVersions`. + +An unused translation key already ships in all 24 locale files: +`'Reconciled in accounting': 'Afstemt i regnskabet'`. + +**Consequence: no base-repo change, no migration, no NuGet bump.** + +### 4.1 The lock predicate — derived, never stored + +``` +lockedThrough(siteId) = MAX(Date) + WHERE SdkSitId = siteId + AND Reconciled = 1 + AND WorkflowState != 'Removed' + -- null when the worker has no reconciled day + +isLocked(siteId, date) ⟺ lockedThrough(siteId) != null + AND date <= lockedThrough(siteId) +``` + +One row carries the mark. Every earlier day is locked by derivation and keeps +`Reconciled = false`, which is requirement (3) satisfied by construction rather +than by a background job that must keep flags in sync. + +Unlocking is therefore *only* meaningful at `lockedThrough` itself: clearing +that day's flag moves the boundary back to the next-newest reconciled day, or +to null. Requirement (4) is a property of the model, not a rule that has to be +separately enforced — a day below the boundary cannot be unlocked because +unlocking it would not change `MAX(Date)`. + +### 4.2 Invariants + +- **I1.** `ReconciledAt` is non-null exactly when `Reconciled` is true. Both are + written in the same operation; unlock clears both. +- **I2.** `Reconciled` may only be set for `date < today`. Reconciling today or + a future day is rejected **server-side**, not merely hidden in the UI. +- **I3.** No write of any kind may modify, create or delete a `PlanRegistration` + whose `Date <= lockedThrough(SdkSitId)`. + +**I2 is load-bearing beyond its obvious purpose.** See §6.2. + +### 4.3 Query cost + +`PlanRegistrations` has a unique index on `(SdkSitId, Date, WorkflowState)`. + +- `isLocked` for a known `(site, date)` is an exact seek — optimal. +- `lockedThrough(site)` is a prefix scan on `SdkSitId` plus a range on `Date`; + `Reconciled` is not in the index, so candidate rows are table lookups. For a + single site over a viewed window this is negligible. The dashboard resolves + it **once per site per request**, not per day, and passes the value down. + +No new index is proposed. If profiling later shows it matters, the narrow fix +is a filtered index on `(SdkSitId, Reconciled, Date)` — but note this repo has +already reverted speculative indexes once (base commit `f086394`), so it should +be added only against a measured query. + +--- + +## 5. Write-path inventory + +There are **32 `PlanRegistration.Create/Update/Delete` call sites across 11 +files in 2 repositories**, and **no choke point**. `PnBase.Create/Update/Delete` +live in the base NuGet package; most services call them directly rather than +through any plugin-owned helper. + +Categories: + +- **Web/admin:** `PlanningService.Update`, `UpdateByCurrentUserNam`, both + `Index` gap-fills, `WorkingHoursService.CreateUpdate` (bulk, multi-day), + `FlexService.UpdateCreate` (+ an estate-wide re-save loop), + `TimeSettingService.UpdateAssignedSite`, `GoogleSheetHelper`, `Import`, + `AbsenceRequestService`, `ContentHandoverService`, `PayrollExportService`. +- **Mobile/gRPC:** thin adapters delegating to the same services, plus a + **kiosk** `UpdateWorkingHour` overload with *no date guard at all*. +- **Recalculation:** `UpdatePlanRegistrationsInPeriod` (4 write sites; runs on + **every dashboard load and every mobile fetch**), `UpdatePlanRegistration`, + and the forward flex cascades. +- **Background (service repo):** `SearchListJob` (8×/day sheet pull; nightly + per-site walk that can **delete** rows), `FlexChainCatchUpJob` (off by + default), `eFormCompletedHandler` (device submissions, can resurrect a + `Removed` row). +- **Startup:** `CorruptedPauseIdRepair` — notable as the **only** path that + already implements a date lock of its own. + +Two behaviours matter more than the raw count: + +**Reading writes.** `POST plannings/index` — the dashboard load — creates a +`PlanRegistration` for every missing date in the requested range, then +`UpdatePlanRegistrationsInPeriod` writes to all of them. Opening a report over +a closed month would, today, create and write rows inside it. + +**Cascades write days nobody edited.** Forward flex cascades run from an edited +day through later days: one bounded at today, one **180 days into the future** +(`WorkingHoursService.CreateUpdate`), and one in the service repo with **no +upper bound at all** (`eFormCompletedHandler`). + +**Every write goes through EF change tracking.** No production path uses +`ExecuteSqlRaw`, `ExecuteUpdate`, `ExecuteDelete` or a bulk insert against +`PlanRegistration`. (A grep finds two `ExecuteSqlRaw` hits in +`TimePlanning.Pn.Test/TestBaseSetup.cs`; both replay a SQL dump into the **SDK** +database during test setup and touch neither the plugin DB nor +`PlanRegistration`.) A `SaveChanges` interceptor can therefore see all 32 sites. + +--- + +## 6. Enforcement + +### 6.1 Three layers + +**Layer 1 — `SaveChangesInterceptor` (the guarantee).** +Rejects any tracked `PlanRegistration` entry that violates **I3**: `Modified` or +`Deleted` with `Date <= lockedThrough`, or `Added` in that range. This is the +only layer that is *complete*: it covers all 32 sites and any site added later. + +It must be registered **in both repositories** wherever a +`TimePlanningPnDbContext` is constructed. Registering it in only one leaves the +background jobs unguarded — a lock with a hole, which is worse than no lock +because it invites trust. + +The interceptor resolves `lockedThrough` per distinct `SdkSitId` in the change +set, once per `SaveChanges`, not per row. + +**Layer 2 — guards on user-facing write paths (the message).** +`PlanningService.Update`, `UpdateByCurrentUserNam`, +`WorkingHoursService.CreateUpdate`, and both `UpdateWorkingHour` overloads +check the predicate first and return a localized failure. Without this the user +sees a 500 from the interceptor instead of "this day is reconciled". + +**Layer 3 — recalculation paths skip, they do not throw.** +`UpdatePlanRegistrationsInPeriod` legitimately spans the boundary on every +dashboard load. It filters locked days out of its working set rather than +failing. Same for `UpdatePlanRegistration` and the service-repo jobs. + +Rationale for all three: guards alone repeat the `MaxDaysEditable` mistake +(bypassable); the interceptor alone turns an ordinary dashboard load into an +exception. + +### 6.2 Why the cascades need no special handling + +Given **I2** (nothing at or after today may be reconciled): + +- every locked day satisfies `date <= lockedThrough < today`; +- every editable day satisfies `date > lockedThrough`; +- a forward cascade starts from an edited day and walks *forward*. + +Therefore every day a cascade touches is `> lockedThrough` and unlocked. **The +cascades provably cannot reach a locked day.** Reading the boundary day to seed +the chain is unaffected — that is a read. + +This is the single reason the 180-day and unbounded cascades do not need to be +re-plumbed. **If I2 is ever relaxed, they become live hazards immediately.** +Any future change permitting a future-dated reconcile must revisit §5's cascade +list first. Layer 1 would catch the violation, but as a 500 rather than a +designed behaviour. + +### 6.3 Gap-fill inside a locked range + +Gap-fill **stops at the boundary**: no row creation, no recomputation for +`date <= lockedThrough`. A locked period is frozen exactly as it stands. + +Consequence, accepted deliberately: a day inside a locked range that never had +a registration stays empty in the grid rather than materialising a blank row. +That is the correct reading — nothing was registered that day — and it is what +makes "reconciled" mean byte-stable rather than merely read-only. + +### 6.4 Timezone + +The predicate in **I2** depends on "today", and the codebase mixes +`DateTime.Now` and `DateTime.UtcNow`. `PlanRegistration.Date` is a midnight +local date. + +**Decision: compare against `DateTime.Now.Date`** (server local), matching how +`PlanRegistrationHelper` and the existing mobile guard already compute "today". +Using UTC would make the boundary shift by a day for part of each evening in +Danish time. This is stated explicitly because it is exactly the kind of +off-by-one that is invisible in tests written at midday. + +--- + +## 7. API surface + +Three operations on `TimePlanningPlanningController`: + +| Verb | Route | Body | Returns | +|---|---|---|---| +| `PUT` | `plannings/{id}/reconcile` | — | `OperationResult` | +| `PUT` | `plannings/{id}/unreconcile` | — | `OperationResult` | +| `PUT` | `plannings/reconcile-through` | `{ date, siteIds[] }` | `OperationResult` with per-site outcome | + +Rules enforced server-side: + +- Reconcile rejects `date >= DateTime.Now.Date` (**I2**). +- Reconcile is idempotent: re-reconciling an already-reconciled day is a no-op + success, not an error. +- Unreconcile rejects any day that is not exactly `lockedThrough` for that + worker, with a message naming the day that must be freed first. +- `reconcile-through` sets `Reconciled` on **one** day per listed worker: the + latest day at or before the given date that has a registration. Everything + earlier locks by derivation. If a worker has no registration on or before the + date, that worker is skipped and reported. + +The read model gains two fields on the existing per-day DTO: + +```csharp +public bool Reconciled { get; set; } +public DateTime? ReconciledAt { get; set; } +``` + +and one per-row field, so the client can render the staircase without computing +it per cell: + +```csharp +public DateTime? LockedThrough { get; set; } +``` + +`isLocked` is then a pure client-side comparison, consistent with how the grid +already derives cell classes. + +--- + +## 8. UI + +### 8.1 Three states + +Because the mark is per worker per day, the boundary is a **staircase** down +the grid, not one vertical line. Each worker has their own reconciled-through +date. + +Four independent channels, so colour is never load-bearing: + +| State | Texture | Glyph | Cursor | Tooltip (da) | +|---|---|---|---|---| +| Open | none | — | `pointer` | — | +| Locked (cascade) | diagonal hatch | outline `lock`, bottom-left | `not-allowed` | `Låst · ligger før en afstemt dag` | +| Afstemt (boundary) | flat tint, 3px right border | filled `verified`, top-right | `default` | `Afstemt kl. ` | + +Constraints from the existing code: + +- `getCellClass(row, field)` returns a **single** string today and has no + composition mechanism. It must be extended to return multiple classes. +- Styles must be **theme-agnostic**: `body.theme-eform` rules do not apply under + `theme-workspace`. Use `--tp-td-bg` / `--tp-border` / `--tp-text`, and define + new `--tp-locked-*` tokens at `:root` with dark overrides. +- `outline` + yellow is already taken by `.highlight-cell`; blue by + `.setting-ico.active`. The lock palette must avoid both. +- Day-cell icons use `fontSet="material-symbols-outlined"` + `class="neutral-icon"`. + `lock` is already in use elsewhere (pay-rule-set banner) and is the right + precedent. + +A legend sits under the grid whenever any locked day is in range — otherwise +nobody learns what the hatch means. + +### 8.2 Single day + +A third action in the workday dialog footer, with a two-step inline confirm +(the footer morphs; no second modal). After saving, the dialog reopens +read-only with a provenance line where the actions were. + +The dialog is chosen over a cell hover affordance deliberately: it is the only +place the user already sees *whose day, which date, which hours* before +committing, and the day cell is a dense click target where a misfire would +freeze a period. + +### 8.3 Multi-day + +Because one date plus the cascade already locks everything before it, +"multi-day" means **multi-worker**. A toolbar control **"Afstem til og med +\"** sets the boundary for many workers in one action, with a live +preview of the affected count before committing. + +Worker selection uses mtx-grid's `[rowSelectable]` / `[multiSelectable]`, which +this plugin has never used. The host's backend-configuration task-list is the +precedent and documents two gotchas that apply verbatim: + +1. mtx-grid binds `(click)="_selectRow()"` on the ``; with `[rowSelectable]` + this **clears the batch selection** when a day cell is clicked. The day cell + must call `stopRowClick($event)`. +2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` + **without emitting** `rowSelectedChange`, so the component must re-emit an + empty selection itself. + +Both are load-bearing: the planning grid's day cells are clickable, so (1) is +guaranteed to bite. + +### 8.4 Unlock + +Only the boundary day offers unlock; it moves the line back one notch. A day +below the boundary shows *which* day must be freed first rather than a disabled +control with no explanation. + +Confirmation is a typed word, not a checkbox — deliberately asymmetric: sealing +takes a click, unsealing takes a word. + +### 8.5 Blocked feedback + +A locked day's dialog **opens read-only** rather than refusing to open. People +read closed days constantly. Every field renders at full opacity, disabled, with +a banner at the top. This reuses the existing `tp-help-hint tone="warn"` pattern +already used for `dayCell.futureDisabled`. + +**Copy rule:** user-facing text states what the day *is*. It never explains a +restriction by referring to what an administrator may do. ("admin = Microting" +is a standing constraint in this product.) + +### 8.6 Permissions + +Per explicit decision: **any web user may reconcile.** This is a deliberate +departure from the rest of the toolbar — payroll export, for instance, is +admin-gated — and it means an ordinary user can freeze a period. The +reverse-order unlock rule is the only safeguard, and it is the reason unlock +carries the heavier confirmation. + +--- + +## 9. Testing + +Tests in this repo run **only in CI**. + +**C# (`TimePlanning.Pn.Test`):** +- `lockedThrough` with: no reconciled day, one, several, one soft-deleted. +- `isLocked` at the boundary, either side of it, and for a worker with none. +- **I2**: reconcile rejected for today and for a future date. +- Unreconcile rejected below the boundary, accepted at it, and the boundary + moving back to the next-newest. +- Interceptor: `Modified`, `Added` and `Deleted` in a locked range each + rejected; each allowed above the boundary. +- Gap-fill creates nothing inside a locked range. +- `UpdatePlanRegistrationsInPeriod` leaves locked rows byte-identical — + asserted on `Version`/`UpdatedAt`, so a no-op re-save still fails the test. +- `reconcile-through` marks exactly one day per worker and skips workers with + no registration. + +New test classes must be added to the CI shard filters in **both** +`dotnet-core-pr.yml` and `dotnet-core-master.yml`, or they silently never run. + +**Playwright:** reconcile a day → cell shows the boundary treatment → earlier +cell shows the locked treatment → opening a locked day gives a read-only dialog +→ unlock below the boundary is refused → unlock at the boundary moves it back. +Anchor rows by worker identity, not by grid index. + +--- + +## 10. Risks + +| Risk | Mitigation | +|---|---| +| Interceptor registered in only one repo → silent hole | Explicit test in each repo asserting a locked write is rejected through that repo's own context construction | +| **I2** relaxed later → cascades reach locked days | Stated as an invariant here and in code comments at the cascade sites; interceptor catches it as a 500 rather than corruption | +| Any user can freeze a period | Accepted by decision; reverse-order unlock + typed confirmation | +| Row selection breaks day-cell clicks | Known mtx-grid gotchas documented in §8.3, both with existing fixes in the host repo | +| `MaxDaysEditable` remains bypassable | Out of scope, explicitly; the interceptor makes the *new* lock not share the flaw | +| Timezone off-by-one near midnight | `DateTime.Now.Date` fixed in §6.4; tests must include a late-evening case | + +## 11. Open questions + +1. Should `reconcile-through` apply to **selected** workers only, or to every + worker currently visible under the active filters? Selected is safer; + all-visible is faster for the common month-end case. +2. Should a locked day still be exportable to payroll, and should + `TransferredToPayroll` and `Reconciled` be related at all? They are + independent in this design. +3. Does the mobile app need to *display* the locked state, or is rejecting the + write sufficient for now? From bda60f21e7cc21a98bed1b8210ba8d3616d7268f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 09:07:47 +0200 Subject: [PATCH 02/18] docs: settle bulk scope, payroll independence and mobile handling Bulk reconcile needs three selection modes -- picked rows, a picked day column, or everything visible in the chosen period. They collapse into one operation with two axes, a target date and a set of workers, rather than three separate features: the date comes from a column header or the picker, and the worker set comes from row selection, defaulting to every visible row. Recorded as a table mapping each requested mode onto that one mechanism. Two consequences worth writing down. The affected cells are previewed in place before committing, so the cascade is something you see rather than infer. And a row already reconciled past the target date is skipped, not moved backwards -- that would be an unlock, which is deliberately the heavier action; skipped rows are counted and reported instead of quietly dropped. Reconciled and TransferredToPayroll stay independent. They are adjacent columns from the same migration, which makes it easy to assume they are related; they are not, in either direction. Mobile rejects the write with the same localized failure as the web paths and renders no lock state. No mobile UI work is in scope. Open questions section now holds nothing blocking -- just the skipped-row reporting surface and whether ReconciledBy is wanted on the record, which would need the base migration this design otherwise avoids. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-12-reconciled-day-lock-design.md | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index 4a397e3c..c69d5856 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -335,9 +335,36 @@ freeze a period. ### 8.3 Multi-day Because one date plus the cascade already locks everything before it, -"multi-day" means **multi-worker**. A toolbar control **"Afstem til og med -\"** sets the boundary for many workers in one action, with a live -preview of the affected count before committing. +"multi-day" means **multi-worker**. Every bulk action is therefore the same +operation with two axes: + +``` +scope = target date × set of workers +``` + +- **The date** comes from clicking a day-column header, or from the toolbar's + date field. One date, never a range — the cascade supplies the range. +- **The worker set** comes from row selection; with nothing selected it + defaults to **every worker currently visible** under the active filters. + +That single mechanism covers all three requested modes without three separate +features: + +| Requested mode | How it is expressed | +|---|---| +| Selected rows | tick rows, then pick a date | +| Selected column | click a day header; no row selection ⇒ all visible workers | +| All visible in the period | pick a date with nothing selected | + +**The affected region is previewed before it is committed** — the cells that +will be locked are highlighted in place, so the cascade is visible rather than +inferred. The confirm step states the two counts that matter: how many workers, +and the date the boundary lands on. + +**Rows already reconciled past the target date are skipped, not moved +backwards.** Applying an earlier date to a worker whose boundary is already +later would be an *unlock*, and unlocking is deliberately a separate, heavier +action (§8.4). Those rows are reported as skipped rather than silently ignored. Worker selection uses mtx-grid's `[rowSelectable]` / `[multiSelectable]`, which this plugin has never used. The host's backend-configuration task-list is the @@ -422,13 +449,27 @@ Anchor rows by worker identity, not by grid index. | `MaxDaysEditable` remains bypassable | Out of scope, explicitly; the interceptor makes the *new* lock not share the flaw | | Timezone off-by-one near midnight | `DateTime.Now.Date` fixed in §6.4; tests must include a late-evening case | -## 11. Open questions +## 11. Resolved decisions + +These were open during design and are now settled: + +1. **Bulk scope** — all three: selected rows, a selected day column, or every + worker visible in the chosen period. Expressed as one mechanism (date × + worker set) rather than three features; see §8.3. +2. **No link to payroll.** `Reconciled` and `TransferredToPayroll` stay + independent. Reconciling does not affect export eligibility, and exporting + does not reconcile. They are adjacent columns from the same migration and it + would be easy to assume otherwise — they are not related. +3. **Mobile rejects the write and shows no lock state.** The gRPC paths return + the same localized failure as the web paths. No mobile UI work is in scope; + the app does not need to render the three states. + +## 12. Open questions + +None blocking. Two worth revisiting after the first release: -1. Should `reconcile-through` apply to **selected** workers only, or to every - worker currently visible under the active filters? Selected is safer; - all-visible is faster for the common month-end case. -2. Should a locked day still be exportable to payroll, and should - `TransferredToPayroll` and `Reconciled` be related at all? They are - independent in this design. -3. Does the mobile app need to *display* the locked state, or is rejecting the - write sufficient for now? +- Whether the skipped-rows report in §8.3 needs a persistent surface, or + whether naming the count in the result toast is enough. +- Whether `ReconciledBy` is wanted on the face of the record. It is currently + recoverable from `PlanRegistrationVersion.UpdatedByUserId` (§3), and adding + it would require the base-repo migration this design otherwise avoids. From f17cfdc500895ad30b4dd2ef732231bc4c9eb812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 09:43:09 +0200 Subject: [PATCH 03/18] docs: implementation plan for the Reconciled day lock 15 tasks across three repos, phased so the backend is complete and enforceable at task 6. Revised after review, which found the first draft would not have survived execution. The defects worth recording: The interceptor opened a second DbContext to read the boundary, and built it with ServerVersion.AutoDetect -- which opens a connection and runs a version query. That guard fires on every save, including the four Update calls inside the per-day loop that runs on every dashboard load; 50 workers over a month would have meant thousands of extra connections per page view. It now queries the context it is already inside, which is safe because the boundary query projects into an anonymous type and tracks nothing. The same mistake in miniature: the rewritten context helper used AutoDetect where the factory it replaced hardcodes the version, adding a round-trip per worker per load. Hardcoded to match. Task 5 said to iterate a filtered collection while still projecting the unfiltered one. UpdatePlanRegistrationsInPeriod has a single 715-line loop that fuses writing and projection, so following that instruction would have dropped locked days out of the grid -- silently wrong rather than blocked, which is the worst failure mode for a plan. Now: guard the four Update calls, leave the loop alone. LockedThrough was set inside that loop, so a worker with no rows in the window never got one and a fully locked month would have rendered editable. A `using Pomelo...` line that does not compile -- this repo uses the Microting fork. Three tests that fail in arrange because the seed order put the row inside the range its own Create would be blocked by. A test that seeded WorkflowState = Removed, which PnBase.Create overwrites unconditionally. Three test helpers that are private or do not exist. Seven wrong line numbers. Also corrected: the rationale for guarding setDisabled claimed the cascade re-enables unconditionally. It does not -- every enable is value-guarded. The true reason is that none is guarded by isInTheFuture, which is a pre-existing bug the lock would inherit. An agent that checked the false premise would have skipped a load-bearing step. Spec UI requirements 8.1-8.4 (glyph, tooltip, legend, two-step confirm, bulk preview, typed unlock confirmation) have no tasks and are listed as an open decision rather than quietly dropped. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-09-12-reconciled-day-lock.md | 2530 +++++++++++++++++ 1 file changed, 2530 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-reconciled-day-lock.md diff --git a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md new file mode 100644 index 00000000..c0fd0446 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md @@ -0,0 +1,2530 @@ +# Reconciled ("Afstemt") Day Lock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a web user mark a worker's day "Afstemt", which freezes that day and every earlier day for that worker against edits and recalculation, unlockable only in reverse order. + +**Architecture:** The lock is *derived*, never stored: a day is locked when that worker has any `Reconciled` day at or after it. `Reconciled`/`ReconciledAt` already exist in the DB. Enforcement is three layers — a `SaveChangesInterceptor` that makes the rule unbypassable, friendly guards on user-facing write paths so blocked edits get a message instead of a 500, and recalculation paths that skip locked days silently. + +**Tech Stack:** C# / .NET 10 / EF Core 10 / MySQL via the **Microting.EntityFrameworkCore.MySql** fork (NOT Pomelo — see Task 2); Angular 20.3 NgModule (`standalone: false`), Angular Material + CDK 20.2.14, `mtx-grid` 20.4.2, ngx-translate 17; NUnit + NSubstitute + Testcontainers; Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md` + +## Global Constraints + +- **Dev mode: NONE — edit the source repos directly.** Every path in this plan is a + source-repo path and every task ends in a commit there. Do **not** run + `devgetchanges.sh`, and do not edit the host-app mirror under + `eform-angular-frontend/eFormAPI/Plugins/` — that mirror is stale (dated Aug 14) + and syncing from it would overwrite this work with old code. The one exception is + Task 10, which edits `eform-angular-frontend`'s own SCSS because CLAUDE.md puts all + SCSS there. + +- **No base-repo change.** `Reconciled` (`tinyint(1) NOT NULL DEFAULT 0`) and `ReconciledAt` (`datetime(6) NULL`) already exist on `PlanRegistrations` **and** `PlanRegistrationVersions` (migration `20260127060748`), and ship in the pinned `Microting.TimePlanningBase` **10.0.62**. Do not add a migration. Do not bump the package. +- **Invariant I1:** `ReconciledAt` is non-null exactly when `Reconciled` is true. Both written together; unlock clears both. +- **Invariant I2:** `Reconciled` may only be set for `date < DateTime.Now.Date`. Server-enforced, not merely hidden in the UI. +- **Invariant I3:** No write may modify, create or delete a `PlanRegistration` whose `Date <= lockedThrough(SdkSitId)`. +- **I2 is load-bearing beyond its own purpose.** Because the boundary is always in the past and edits are only allowed above it, forward flex cascades (one runs 180 days ahead, one is unbounded) provably cannot reach a locked day. If I2 is ever relaxed, those cascades must be revisited first. +- **Timezone:** compare against `DateTime.Now.Date` (server local), matching `PlanRegistrationHelper` and the existing mobile guard. Never `UtcNow`. +- **Copy rule:** user-facing text states what the day *is*. It never explains a restriction by referring to what an administrator may do. ("admin = Microting".) +- **Permissions:** any web user may reconcile. No admin gate on reconcile or unlock. +- **Payroll:** `Reconciled` and `TransferredToPayroll` are independent in both directions. Do not couple them. +- **Mobile:** rejects the write with the same localized failure as web. No mobile UI work. +- **Tests run only in CI.** Never run `dotnet test`, `playwright test`, `jest` or `npm test` locally — a hook blocks them. `dotnet build` is allowed and expected. Push and watch `gh pr checks `. +- **Never commit to `stable` directly.** Branch, PR into `stable`. +- **SCSS lives in `eform-angular-frontend`**, never per-plugin (CLAUDE.md). Task 10 is a separate repo and a separate PR. +- **New C# test classes must be added to the shard filters in BOTH** `.github/workflows/dotnet-core-pr.yml` and `dotnet-core-master.yml`, or they silently never run. + +--- + +## File Structure + +**New files** + +| Path | Responsibility | +|---|---| +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs` | The lock predicate. Sole source of truth for `lockedThrough` and `IsLocked`. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` | Stateless `SaveChangesInterceptor` enforcing I3 across every write path. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs` | Bulk request body. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs` | Bulk result (applied / skipped / landed-on). | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs` | Predicate unit tests. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs` | Interceptor enforcement tests. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs` | Endpoint behaviour tests. | +| `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` | E2E. | + +**Modified — backend** + +| Path | Change | +|---|---| +| `.../Infrastructure/Helpers/TimePlanningDbContextHelper.cs` | Build options locally; attach interceptor. | +| `.../EformTimePlanningPlugin.cs:183-189` | `.AddInterceptors(...)` on the pooled context. | +| `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` | Guards; three new service methods; `LockedThrough` on the row model. | +| `.../Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs` | Three new signatures. | +| `.../Controllers/TimePlanningPlanningController.cs` | Three new routes. | +| `.../Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` | Guards on `CreateUpdate` and both `UpdateWorkingHour` overloads. | +| `.../Infrastructure/Helpers/PlanRegistrationHelper.cs` | Skip locked days; project the two DTO fields. | +| `.../Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs` | `+Reconciled`, `+ReconciledAt`. | +| `.../Infrastructure/Models/Planning/TimePlanningPlanningModel.cs` | `+LockedThrough`. | +| `.../Resources/Translations.resx` + `Translations.da.resx` | Six new keys. | +| `.../TimePlanning.Pn.Test/TestBaseSetup.cs` | Attach the interceptor so tests exercise it. | + +**Modified — frontend (plugin repo)** + +`models/plannings/planning-pr-day.model.ts`, `models/plannings/time-planning.model.ts`, `services/time-planning-pn-plannings.service.ts`, `components/plannings/time-plannings-table/time-plannings-table.component.{ts,html}`, `components/plannings/time-plannings-container/time-plannings-container.component.{ts,html}`, `components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.{ts,html}`, `help/help.model.ts`, `help/planning-help.registry.ts`, `help/i18n/{da,enUS}.ts`, `i18n/{da,enUS}.ts`. + +**Modified — host repo (separate PR):** `eform-client/src/scss/styles.scss`. + +--- + +## Task 1: The lock predicate + +**Files:** +- Create: `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs` +- Test: `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs` +- Modify: `.github/workflows/dotnet-core-pr.yml:251`, `.github/workflows/dotnet-core-master.yml:262` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) -> Task` + - `DayLockHelper.LockedThroughForSitesAsync(TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) -> Task>` + - `DayLockHelper.IsLocked(DateTime? lockedThrough, DateTime date) -> bool` + - `DayLockHelper.CanReconcile(DateTime date) -> bool` + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/DayLockHelperTests.cs`: + +```csharp +using System; +using System.Threading.Tasks; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The lock is derived, never stored: a day is locked when the worker has any +/// Reconciled day at or after it. These tests pin that derivation, because +/// every enforcement layer downstream trusts it. +/// +[TestFixture] +public class DayLockHelperTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + // No workflowState parameter: PnBase.Create overwrites it with "created" + // regardless of what is set here. + private async Task Seed(int site, DateTime date, bool reconciled) + { + await new PlanRegistrationEntity + { + SdkSitId = site, + Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + + [Test] + public async Task LockedThrough_NoReconciledDay_IsNull() + { + await Seed(700, new DateTime(2026, 1, 12), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 700); + + Assert.That(result, Is.Null, "a worker with nothing reconciled has no boundary"); + } + + [Test] + public async Task LockedThrough_SeveralReconciled_IsTheLatest() + { + await Seed(701, new DateTime(2026, 1, 12), reconciled: true); + await Seed(701, new DateTime(2026, 1, 16), reconciled: true); + await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 16))); + } + + [Test] + public async Task LockedThrough_IgnoresRemovedRows() + { + // PnBase.Create OVERWRITES WorkflowState with "created" unconditionally + // (PnBase.cs:16), so a row cannot be seeded as Removed -- it has to be + // created and then soft-deleted. Note this test must run BEFORE the + // interceptor exists (Task 2) or the Delete below lands on the boundary + // day and is rejected; from Task 2 onward, delete the LATER row first + // while the earlier one still holds no boundary. + await Seed(702, new DateTime(2026, 1, 12), reconciled: true); + await Seed(702, new DateTime(2026, 1, 18), reconciled: true); + + var later = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.SdkSitId == 702 && x.Date == new DateTime(2026, 1, 18)); + await later.Delete(TimePlanningPnDbContext!); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 702); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 12)), + "a soft-deleted reconciled row must not hold the boundary"); + } + + [Test] + public async Task LockedThrough_IsPerWorker() + { + await Seed(703, new DateTime(2026, 1, 16), reconciled: true); + await Seed(704, new DateTime(2026, 1, 12), reconciled: false); + + // Await FIRST, then assert synchronously. Assert.Multiple(async () => ...) + // binds to the Action overload, making the lambda async void: its + // assertions can run after the block exits, and a failure is then lost + // or blamed on the wrong test. NUnit.Analyzers flags this. + var seven03 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 703); + var seven04 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 704); + + Assert.Multiple(() => + { + Assert.That(seven03, Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(seven04, Is.Null, "one worker's boundary must not leak onto another"); + }); + } + + [Test] + public async Task LockedThroughForSites_ResolvesManyInOneQuery() + { + await Seed(705, new DateTime(2026, 1, 16), reconciled: true); + await Seed(706, new DateTime(2026, 1, 13), reconciled: true); + await Seed(707, new DateTime(2026, 1, 13), reconciled: false); + + var map = await DayLockHelper.LockedThroughForSitesAsync( + TimePlanningPnDbContext!, new[] { 705, 706, 707 }); + + Assert.Multiple(() => + { + Assert.That(map[705], Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(map[706], Is.EqualTo(new DateTime(2026, 1, 13))); + Assert.That(map[707], Is.Null); + Assert.That(map.Count, Is.EqualTo(3), "every requested site gets an entry"); + }); + } + + [TestCase("2026-01-10", false, TestName = "IsLocked_BeforeBoundary_True")] + [TestCase("2026-01-16", false, TestName = "IsLocked_AtBoundary_True")] + [TestCase("2026-01-17", true, TestName = "IsLocked_AfterBoundary_False")] + public void IsLocked_RelativeToBoundary(string date, bool expectedOpen) + { + var boundary = new DateTime(2026, 1, 16); + + var locked = DayLockHelper.IsLocked(boundary, DateTime.Parse(date)); + + Assert.That(locked, Is.EqualTo(!expectedOpen)); + } + + [Test] + public void IsLocked_NoBoundary_NothingIsLocked() + { + Assert.That(DayLockHelper.IsLocked(null, new DateTime(2020, 1, 1)), Is.False); + } + + [Test] + public void IsLocked_IgnoresTimeOfDay() + { + var boundary = new DateTime(2026, 1, 16); + + Assert.That(DayLockHelper.IsLocked(boundary, new DateTime(2026, 1, 16, 23, 59, 59)), + Is.True, "the boundary day is locked for its whole length"); + } + + [Test] + public void CanReconcile_TodayAndFuture_False_Past_True() + { + Assert.Multiple(() => + { + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date), Is.False, + "today must stay open so time can still be registered"); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(1)), Is.False); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(-1)), Is.True); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddHours(23)), Is.False, + "a time-of-day on today is still today"); + }); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +This repo runs tests only in CI. Verify the failure shape locally with a build instead: + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `error CS0103: The name 'DayLockHelper' does not exist in the current context` + +- [ ] **Step 3: Write minimal implementation** + +Create `TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Helpers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; + +/// +/// The single source of truth for "is this day locked". +/// +/// The lock is DERIVED, never stored. A worker's boundary is the latest date +/// they have a Reconciled registration on; every day at or before it is locked. +/// Earlier days are therefore locked WITHOUT being marked Reconciled, and a day +/// below the boundary cannot be unlocked because unlocking it would not move +/// MAX(Date) -- both requirements fall out of the model instead of needing a +/// job to keep flags in sync. +/// +public static class DayLockHelper +{ + /// + /// The latest reconciled date for one worker, or null when they have none. + /// Soft-deleted rows never hold the boundary. + /// + public static async Task LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) + { + return await db.PlanRegistrations + .Where(x => x.SdkSitId == sdkSitId) + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .MaxAsync(x => (DateTime?)x.Date) + .ConfigureAwait(false); + } + + /// + /// Boundaries for many workers in ONE query. Callers that render a grid + /// resolve this once per request rather than once per day cell. + /// Every requested site gets an entry; sites with no reconciled day map to null. + /// + public static async Task> LockedThroughForSitesAsync( + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + { + var found = await db.PlanRegistrations + .Where(x => sdkSitIds.Contains(x.SdkSitId)) + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .GroupBy(x => x.SdkSitId) + .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) + .ToListAsync() + .ConfigureAwait(false); + + var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); + foreach (var id in sdkSitIds) + { + map.TryAdd(id, null); + } + return map; + } + + /// + /// Pure predicate, so callers can resolve the boundary once and test many + /// dates against it without touching the database again. + /// + public static bool IsLocked(DateTime? lockedThrough, DateTime date) + => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + + /// + /// Invariant I2: today and future days must stay open so time can still be + /// registered. This is also what makes the forward flex cascades unable to + /// reach a locked day -- see the design doc before relaxing it. + /// + /// DateTime.Now, not UtcNow: PlanRegistration.Date is a local midnight, and + /// the existing mobile guard compares the same way. + /// + public static bool CanReconcile(DateTime date) => date.Date < DateTime.Now.Date; +} +``` + +- [ ] **Step 4: Register the test class in BOTH CI shard filters** + +Without this the tests silently never run. In `.github/workflows/dotnet-core-pr.yml` line 251 and `.github/workflows/dotnet-core-master.yml` line 262, append to the shard `c` filter string: + +``` +|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests +``` + +- [ ] **Step 5: Build** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +- [ ] **Step 6: Commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): derive the reconciled-day boundary from the data" +``` + +--- + +## Task 2: The enforcement interceptor + +**Files:** +- Create: `.../TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` +- Modify: `.../TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs` (whole file) +- Modify: `.../TimePlanning.Pn/EformTimePlanningPlugin.cs:183-189` +- Modify: `.../TimePlanning.Pn.Test/TestBaseSetup.cs:32-42` and `:113-122` +- Test: `.../TimePlanning.Pn.Test/DayLockInterceptorTests.cs` +- Modify: both workflow files + +**Interfaces:** +- Consumes: `DayLockHelper.LockedThroughForSitesAsync`, `DayLockHelper.IsLocked` (Task 1). +- Produces: `ReconciledDayLockInterceptor` (stateless, parameterless ctor); `DayLockedException : InvalidOperationException`. + +**Why an interceptor at all:** there are 32 `PlanRegistration` write sites across 11 files in 2 repos and no choke point — `PnBase.Create/Update/Delete` live in the base NuGet. Guarding call sites alone would repeat the `MaxDaysEditable` mistake, which is enforced only on the read path and in the browser and is bypassable by a crafted POST today. Every write does go through EF change tracking (no raw SQL, no `ExecuteUpdate`), so one interceptor sees all of them. + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/DayLockInterceptorTests.cs`: + +```csharp +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The interceptor is the only layer that is COMPLETE -- it covers all 32 +/// PlanRegistration write sites and anything added later. These tests go +/// through a context built exactly as production builds it (interceptor +/// attached), so they prove the wiring, not just the class. +/// +[TestFixture] +public class DayLockInterceptorTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + private async Task SeedReconciled(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + private async Task SeedPlain(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + [Test] + public async Task Modifying_ADayBelowTheBoundary_IsRejected() + { + // Order matters: create the earlier row FIRST. Seeding the boundary + // first would put this Create inside the locked range, and the arrange + // step would throw before the assertion was ever reached. + var earlier = await SeedPlain(800, new DateTime(2026, 1, 13)); + await SeedReconciled(800, new DateTime(2026, 1, 16)); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_TheBoundaryDayItself_IsRejected() + { + var boundary = await SeedReconciled(801, new DateTime(2026, 1, 16)); + + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Creating_ARowInsideALockedRange_IsRejected() + { + await SeedReconciled(802, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await SeedPlain(802, new DateTime(2026, 1, 14))); + } + + [Test] + public async Task SoftDeleting_ALockedRow_IsRejected() + { + // PnBase.Delete is a soft delete -- it sets WorkflowState = Removed via + // UpdateInternal, so this arrives at the interceptor as Modified, not + // Deleted. The name says SoftDeleting so nobody reads a pass here as + // proof that the EntityState.Deleted arm works. + var earlier = await SeedPlain(803, new DateTime(2026, 1, 13)); + await SeedReconciled(803, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await earlier.Delete(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_ADayAboveTheBoundary_IsAllowed() + { + var later = await SeedPlain(804, new DateTime(2026, 1, 18)); + await SeedReconciled(804, new DateTime(2026, 1, 16)); + + later.PlanHours = 9; + await later.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == later.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "days after the boundary must stay fully editable"); + } + + [Test] + public async Task ANotherWorkersBoundary_DoesNotLockThisWorker() + { + var other = await SeedPlain(806, new DateTime(2026, 1, 13)); + await SeedReconciled(805, new DateTime(2026, 1, 16)); + + other.PlanHours = 9; + await other.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == other.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "the boundary is per worker; it must not leak across sites"); + } + + [Test] + public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() + { + // Unlocking must not be blocked by the very lock it is clearing. + var boundary = await SeedReconciled(807, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + await boundary.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.False, + "clearing the flag on the boundary day is how unlocking works"); + } +} +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `error CS0246: The type or namespace name 'DayLockedException' could not be found` + +- [ ] **Step 3: Write the interceptor** + +Create `TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Interceptors; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Helpers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microting.TimePlanningBase.Infrastructure.Data; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +/// +/// Thrown when a write would touch a day at or before the worker's reconciled +/// boundary. Distinct from a generic InvalidOperationException so the friendly +/// guards in the services can tell "the lock stopped this" from "something else +/// broke" -- and so a stray one is legible in Sentry. +/// +public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThrough) + : InvalidOperationException( + $"Day {date:yyyy-MM-dd} for site {sdkSitId} is locked: reconciled through {lockedThrough:yyyy-MM-dd}.") +{ + public int SdkSitId { get; } = sdkSitId; + public DateTime Date { get; } = date; + public DateTime LockedThrough { get; } = lockedThrough; +} + +/// +/// Enforces invariant I3 across every PlanRegistration write path. +/// +/// STATELESS BY DESIGN. The pooled context registration +/// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an +/// interceptor holding per-request state would leak it between requests. +/// Everything this needs is read from the change tracker on each call. +/// +/// The boundary is resolved ONCE per SaveChanges for the distinct sites in the +/// change set, not once per row. +/// +public class ReconciledDayLockInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + await GuardAsync(eventData.Context, cancellationToken).ConfigureAwait(false); + return await base.SavingChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task GuardAsync(DbContext? context, CancellationToken ct) + { + if (context is not TimePlanningPnDbContext db) + { + return; + } + + // PnBase.Delete is a SOFT delete: it sets WorkflowState = Removed and + // routes through UpdateInternal, so a delete reaches here as Modified, + // never Deleted. The Deleted arm is kept as insurance against a future + // hard delete; do not "simplify" it away on the grounds that it never + // fires today. + var touched = db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .ToList(); + + if (touched.Count == 0) + { + return; + } + + var siteIds = touched.Select(e => e.Entity.SdkSitId).Distinct().ToList(); + + // Query `db` itself. An earlier draft opened a second context here; + // that is a production outage, because ServerVersion.AutoDetect opens a + // connection and runs a version query, and this guard runs on EVERY + // save -- including the four Update calls inside the per-day loop of + // UpdatePlanRegistrationsInPeriod, which runs on every dashboard load. + // 50 workers x 31 days would mean thousands of extra connections per + // page view. + // + // Querying `db` is safe: LockedThroughForSitesAsync projects into an + // anonymous type so it tracks nothing and cannot pollute the change + // tracker; a query never re-enters SaveChanges so there is no + // recursion; and it reuses the open connection and any ambient + // transaction, which a separate context could not see. + var boundaries = await DayLockHelper + .LockedThroughForSitesAsync(db, siteIds) + .ConfigureAwait(false); + + foreach (var entry in touched) + { + var siteId = entry.Entity.SdkSitId; + if (!boundaries.TryGetValue(siteId, out var boundary) || boundary is null) + { + continue; + } + + if (!DayLockHelper.IsLocked(boundary, entry.Entity.Date)) + { + continue; + } + + // The one permitted write inside the locked range: clearing the flag + // on the boundary day itself. That is what unlocking IS, and it must + // not be blocked by the lock it is removing. + if (IsUnlockOfBoundaryDay(entry, boundary.Value)) + { + continue; + } + + throw new DayLockedException(siteId, entry.Entity.Date, boundary.Value); + } + } + + private static bool IsUnlockOfBoundaryDay( + EntityEntry entry, DateTime boundary) + { + if (entry.State != EntityState.Modified) + { + return false; + } + if (entry.Entity.Date.Date != boundary.Date) + { + return false; + } + // Reconciled must be going true -> false, and nothing else about the row + // may be changing in the same save. + var reconciled = entry.Property(x => x.Reconciled); + if (!reconciled.IsModified || (bool)reconciled.CurrentValue! ) + { + return false; + } + var changedOthers = entry.Properties + .Where(p => p.IsModified) + .Select(p => p.Metadata.Name) + .Where(n => n is not (nameof(PlanRegistrationEntity.Reconciled) + or nameof(PlanRegistrationEntity.ReconciledAt) + or nameof(PlanRegistrationEntity.UpdatedAt) + or nameof(PlanRegistrationEntity.Version) + or nameof(PlanRegistrationEntity.UpdatedByUserId))) + .ToList(); + + return changedOthers.Count == 0; + } +} +``` + +- [ ] **Step 4: Wire it into the web helper** + +Replace the body of `TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs` entirely: + +```csharp +using System; +using Microsoft.EntityFrameworkCore; +using Microting.TimePlanningBase.Infrastructure.Data; +using TimePlanning.Pn.Infrastructure.Interceptors; +// NB: no Pomelo using. This repo uses the Microting.EntityFrameworkCore.MySql +// fork, and MariaDbServerVersion/ServerVersion come from +// Microsoft.EntityFrameworkCore -- which is why EformTimePlanningPlugin.cs +// needs no provider-specific using either. Adding a Pomelo PackageReference +// would introduce a second, conflicting provider. + +namespace TimePlanning.Pn.Infrastructure.Helpers; + +/// +/// Builds plugin DbContexts with the day-lock interceptor attached. +/// +/// This no longer delegates to TimePlanningPnContextFactory: that factory +/// builds its DbContextOptionsBuilder in a method-local and exposes no hook, so +/// there is no way to attach an interceptor through it. The context's public +/// options constructor is the supported seam, and it needs no base-package change. +/// +public class TimePlanningDbContextHelper(string connectionString) : ITimePlanningDbContextHelper +{ + private string ConnectionString { get; } = connectionString; + + public TimePlanningPnDbContext GetDbContext() + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Hardcoded version, exactly as TimePlanningPnContextFactory does + // (factory line 39). ServerVersion.AutoDetect OPENS A CONNECTION and + // runs a version query; this method is called once per assigned site on + // every dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); + + return new TimePlanningPnDbContext(optionsBuilder.Options); + } +} + +public interface ITimePlanningDbContextHelper +{ + TimePlanningPnDbContext GetDbContext(); +} +``` + +- [ ] **Step 5: Wire it into the pooled registration** + +In `TimePlanning.Pn/EformTimePlanningPlugin.cs`, change lines 183-189 to add the interceptor. The pool reuses instances, which is safe here only because the interceptor is stateless: + +```csharp + services.AddDbContextPool(o => + o.UseMySql(connectionString, new MariaDbServerVersion( + ServerVersion.AutoDetect(connectionString)), mySqlOptionsAction: builder => + { + builder.EnableRetryOnFailure(); + builder.MigrationsAssembly(PluginAssembly().FullName); + }) + .AddInterceptors(new ReconciledDayLockInterceptor())); +``` + +Add at the top of the file: `using TimePlanning.Pn.Infrastructure.Interceptors;` + +- [ ] **Step 6: Make the tests exercise the real wiring** + +`TestBaseSetup` builds its own contexts, so without this the tests would bypass the interceptor entirely and the lock would look tested while being unenforced. + +In `TimePlanning.Pn.Test/TestBaseSetup.cs`, add `using TimePlanning.Pn.Infrastructure.Interceptors;` and append `.AddInterceptors(new ReconciledDayLockInterceptor())` to the options in **both** `GetTimePlanningPnDbContext` (after line 40) and `CreateTimePlanningPnDbContext` (after line 120): + +```csharp + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); +``` + +- [ ] **Step 7: Register the test class in BOTH shard filters** + +Append to the shard `c` filter in `dotnet-core-pr.yml:251` and `dotnet-core-master.yml:262`: + +``` +|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests +``` + +- [ ] **Step 8: Build** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +- [ ] **Step 9: Commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): enforce the day lock at the SaveChanges boundary" +``` + +--- + +## Task 3: Localized messages + +**Files:** +- Modify: `.../TimePlanning.Pn/Resources/Translations.resx` (append before `` at :204) +- Modify: `.../TimePlanning.Pn/Resources/Translations.da.resx` + +**Interfaces:** +- Produces: six resx keys consumed by Tasks 4 and 5. + +Note: several existing keys (`PlanningNotFound`, `ErrorWhileUpdatingPlanning`) are **not** in any resx — `IStringLocalizer` returns the key itself when unresolved. Do not follow that precedent; add these properly. + +- [ ] **Step 1: Add the English entries** + +In `Translations.resx`, before ``: + +```xml + + This day is reconciled. The figures are final. + + + This day is locked because it is before a reconciled day. + + + Only days before today can be reconciled. + + + Unlock the most recent reconciled day first. + + + Day reconciled + + + Day unlocked + +``` + +- [ ] **Step 2: Add the Danish entries** + +In `Translations.da.resx`, before ``: + +```xml + + Dagen er afstemt. Tallene er endelige. + + + Dagen er låst, fordi den ligger før en afstemt dag. + + + Kun dage før i dag kan afstemmes. + + + Lås den seneste afstemte dag op først. + + + Dagen er afstemt + + + Dagen er låst op + +``` + +- [ ] **Step 3: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +git commit -m "feat(lock): add the day-lock messages in English and Danish" +``` + +--- + +## Task 4: Reconcile / unlock / bulk endpoints + +**Files:** +- Create: `.../Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs` +- Create: `.../Infrastructure/Models/Planning/ReconcileThroughResultModel.cs` +- Modify: `.../Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs` +- Modify: `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` (append three methods) +- Modify: `.../Controllers/TimePlanningPlanningController.cs` +- Test: `.../TimePlanning.Pn.Test/ReconcileServiceTests.cs` +- Modify: both workflow files + +**Interfaces:** +- Consumes: `DayLockHelper.*` (Task 1); resx keys (Task 3). +- Produces: + - `ITimePlanningPlanningService.Reconcile(int id) -> Task` + - `ITimePlanningPlanningService.Unreconcile(int id) -> Task` + - `ITimePlanningPlanningService.ReconcileThrough(ReconcileThroughRequestModel model) -> Task>` + - `ReconcileThroughRequestModel { DateTime Date; List SiteIds; }` + - `ReconcileThroughResultModel { Dictionary LandedOnBySiteId; int Applied; List SkippedAlreadyFurtherForward; List SkippedNoRegistration; List AlreadyReconciledSiteIds; }` + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/ReconcileServiceTests.cs`. + +**Three of the four symbols these tests use are not reachable from a new fixture — you must provide them:** + +| symbol | status | what to do | +|---|---|---| +| `GetBaseDbContext()` | `protected` on `TestBaseSetup:85` | inherited, use as-is | +| `_service` | `private` field of `PlanningServiceMultiShiftTests:31` | declare your own | +| `BuildAdminIndexServiceAsync` | `private`, `PlanningServiceMultiShiftTests:925-957` | copy it into this fixture | +| `OneDayRequest` | `private`, `:959-963` | copy it | +| `SeedPlain` | **does not exist anywhere** | write it (below) | + +Mirror the `[SetUp]` from `PlanningServiceMultiShiftTests.cs:38-81` verbatim (same substitutes, same service construction, same `_service`/`_userService`/`_dbContextHelper`/`_options` fields), copy `BuildAdminIndexServiceAsync` and `OneDayRequest` verbatim, and add this seed helper: + +```csharp + /// + /// One plain PlanRegistration. Returns the tracked entity so tests can + /// reconcile it by Id. Note PnBase.Create forces WorkflowState to "created". + /// + private async Task SeedPlain(int siteId, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = siteId, + Date = date, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } +``` + +Then the tests: + +```csharp + [Test] + public async Task Reconcile_APastDay_SetsFlagAndTimestamp() + { + var row = await SeedPlain(900, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.True); + Assert.That(reloaded.ReconciledAt, Is.Not.Null, "I1: the timestamp is written with the flag"); + }); + } + + [Test] + public async Task Reconcile_Today_IsRejected() + { + var row = await SeedPlain(901, DateTime.Now.Date); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture"), + "I2: today must stay open so time can still be registered"); + } + + [Test] + public async Task Reconcile_AFutureDay_IsRejected() + { + var row = await SeedPlain(902, DateTime.Now.Date.AddDays(3)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture")); + } + + [Test] + public async Task Reconcile_AnAlreadyReconciledDay_IsAnIdempotentSuccess() + { + var row = await SeedPlain(903, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var again = await _service.Reconcile(row.Id); + + Assert.That(again.Success, Is.True, "re-reconciling is a no-op, not an error"); + } + + [Test] + public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay() + { + var earlier = await SeedPlain(904, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(904, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), + "the puzzle rule: free the outermost piece first"); + } + + [Test] + public async Task Unreconcile_AtTheBoundary_MovesItBackToTheNextNewest() + { + var earlier = await SeedPlain(905, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(905, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(later.Id); + + Assert.That(result.Success, Is.True, result.Message); + var boundary = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 905); + Assert.That(boundary, Is.EqualTo(earlier.Date), + "the boundary moves back one notch, it does not vanish"); + } + + [Test] + public async Task ReconcileThrough_MarksOneDayPerWorker_AndSkipsThoseAlreadyFurtherForward() + { + var aEarly = await SeedPlain(906, DateTime.Now.Date.AddDays(-6)); + var bEarly = await SeedPlain(907, DateTime.Now.Date.AddDays(-6)); + var bLate = await SeedPlain(907, DateTime.Now.Date.AddDays(-2)); + await _service.Reconcile(bLate.Id); // 907's boundary is already newer + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 906, 907 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.Applied, Is.EqualTo(1)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.EquivalentTo(new[] { 907 }), + "moving 907 backwards would be an unlock, which is a separate action"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty, + "907 was skipped for the other reason -- the two must not be conflated"); + Assert.That(result.Model.LandedOnBySiteId[906], Is.EqualTo(aEarly.Date)); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(907), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_LandsOnTheLatestDayWithARegistration() + { + await SeedPlain(908, DateTime.Now.Date.AddDays(-9)); + // nothing on -8 or -7 + var target = DateTime.Now.Date.AddDays(-7); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = target, SiteIds = new List { 908 } + }); + + Assert.That(result.Model.LandedOnBySiteId[908], Is.EqualTo(DateTime.Now.Date.AddDays(-9)), + "a seal on a day with no registration would mean nothing"); + } +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `'ITimePlanningPlanningService' does not contain a definition for 'Reconcile'` + +- [ ] **Step 3: Add the request/result models** + +Create `Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +/// +/// One date, many workers. The cascade supplies the range, so this never +/// carries a range of its own. +/// +public class ReconcileThroughRequestModel +{ + public DateTime Date { get; set; } + public List SiteIds { get; set; } = new(); +} +``` + +Create `Infrastructure/Models/Planning/ReconcileThroughResultModel.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +public class ReconcileThroughResultModel +{ + /// Where each worker's boundary landed. Per worker, not shared: + /// the mark falls on that worker's latest day with a registration at or + /// before the requested date, so a staircase has no single landing date. + public Dictionary LandedOnBySiteId { get; set; } = new(); + + /// Workers whose boundary actually moved. Excludes no-ops. + public int Applied { get; set; } + + /// Already reconciled at or past the target. Moving them back would + /// be an unlock, which is deliberately a separate, heavier action. + public List SkippedAlreadyFurtherForward { get; set; } = new(); + + /// No registration at or before the target, so there was nothing + /// to mark. A distinct case from the above — the spec distinguishes them. + public List SkippedNoRegistration { get; set; } = new(); + + /// Already marked on exactly the landing day; nothing changed. + public List AlreadyReconciledSiteIds { get; set; } = new(); +} +``` + +- [ ] **Step 4: Add the three interface members** + +In `ITimePlanningPlanningService.cs`, inside the interface body: + +```csharp + Task Reconcile(int id); + Task Unreconcile(int id); + Task> ReconcileThrough(ReconcileThroughRequestModel model); +``` + +- [ ] **Step 5: Implement the three methods** + +Append to `TimePlanningPlanningService.cs`, inside the class: + +```csharp + public async Task Reconcile(int id) + { + try + { + await using var db = dbContextHelper.GetDbContext(); + var planning = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + + // Idempotent: re-reconciling an already reconciled day is a no-op + // success. Clients retry; that should not read as a failure. + if (planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + + if (!DayLockHelper.CanReconcile(planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Without this, reconciling a day BELOW an existing boundary passes + // CanReconcile, reaches Update, and the interceptor throws into the + // generic catch -- a 500-shaped "ErrorWhileUpdatingPlanning" instead + // of a message. That is exactly what Layer 2 exists to prevent. + var existingBoundary = await DayLockHelper.LockedThroughAsync(db, planning.SdkSitId); + if (DayLockHelper.IsLocked(existingBoundary, planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + + planning.Reconciled = true; + // DateTime.Now, not UtcNow: the tooltip renders this verbatim as + // "Afstemt kl. ", and UTC would read 1-2 hours off in + // Danish time. Consistent with the CanReconcile comparison. + planning.ReconciledAt = DateTime.Now; // I1: written together + planning.UpdatedByUserId = userService.UserId; + await planning.Update(db); + + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + catch (DayLockedException) + { + // Expected and routine: a blocked edit is a normal outcome, not an + // incident. Do not report it to Sentry. + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Reconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task Unreconcile(int id) + { + try + { + await using var db = dbContextHelper.GetDbContext(); + var planning = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + // Boundary check FIRST. If the idempotency check came first, a user + // clicking unlock on a cascade-locked day (Reconciled = false, deep + // inside the range) would be told "Dagen er låst op" while nothing + // happened. + var boundary = await DayLockHelper.LockedThroughAsync(db, planning.SdkSitId); + if (boundary is null || planning.Date.Date != boundary.Value.Date) + { + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + + if (!planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + + planning.Reconciled = false; + planning.ReconciledAt = null; // I1: cleared together + planning.UpdatedByUserId = userService.UserId; + await planning.Update(db); + + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Unreconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task> ReconcileThrough( + ReconcileThroughRequestModel model) + { + try + { + if (model == null || model.SiteIds.Count == 0) + { + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + if (!DayLockHelper.CanReconcile(model.Date)) + { + return new OperationDataResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + await using var db = dbContextHelper.GetDbContext(); + // Distinct: a duplicated site id in the request would otherwise be + // counted twice. + var siteIds = model.SiteIds.Distinct().ToList(); + var boundaries = await DayLockHelper.LockedThroughForSitesAsync(db, siteIds); + var result = new ReconcileThroughResultModel(); + var target = model.Date.Date; + + foreach (var siteId in siteIds) + { + // Already at or past the target: moving the boundary BACK would + // be an unlock, which is deliberately a separate, heavier action. + if (boundaries.TryGetValue(siteId, out var existing) + && existing.HasValue && existing.Value.Date >= target) + { + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + // The mark must land on a day that actually has a registration — + // a seal on an empty day means nothing. + var landing = await db.PlanRegistrations + .Where(x => x.SdkSitId == siteId) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Date <= target) + .OrderByDescending(x => x.Date) + .FirstOrDefaultAsync(); + + if (landing == null) + { + // A different reason from "already further forward", and the + // spec distinguishes them -- do not merge the two lists. + result.SkippedNoRegistration.Add(siteId); + continue; + } + + if (landing.Reconciled) + { + // Already marked on exactly this day: nothing to do, and it + // must not inflate Applied. + result.AlreadyReconciledSiteIds.Add(siteId); + continue; + } + + landing.Reconciled = true; + landing.ReconciledAt = DateTime.Now; // see Reconcile() + landing.UpdatedByUserId = userService.UserId; + await landing.Update(db); + + result.Applied++; + // Per-worker, because the boundary is a staircase: one shared + // LandedOn would name the wrong date for most workers. + result.LandedOnBySiteId[siteId] = landing.Date; + } + + return new OperationDataResult(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.ReconcileThrough failed"); + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } +``` + +Add `using TimePlanning.Pn.Infrastructure.Helpers;` if not already present. + +- [ ] **Step 6: Add the three routes** + +In `Controllers/TimePlanningPlanningController.cs`, matching the file's existing separate-attribute style: + +```csharp + [HttpPut] + [Route("{id}/reconcile")] + public async Task Reconcile(int id) + { + return await _planningService.Reconcile(id); + } + + [HttpPut] + [Route("{id}/unreconcile")] + public async Task Unreconcile(int id) + { + return await _planningService.Unreconcile(id); + } + + [HttpPut] + [Route("reconcile-through")] + public async Task> ReconcileThrough( + [FromBody] ReconcileThroughRequestModel model) + { + return await _planningService.ReconcileThrough(model); + } +``` + +- [ ] **Step 7: Register the test class in BOTH shard filters** + +Append `|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests` to shard `c` in both workflow files. + +- [ ] **Step 8: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): add reconcile, unlock and reconcile-through endpoints" +``` + +--- + +## Task 5: Friendly guards and recalculation skipping + +**Files:** +- Modify: `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` (`Update` :671 (the null check ends :700), `UpdateByCurrentUserNam` ~:1164, gap-fill loops :370-402 and :593-623) +- Modify: `.../Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` (`CreateUpdate` :454, `UpdateWorkingHour` :1339 and :2012) +- Modify: `.../Infrastructure/Helpers/PlanRegistrationHelper.cs` (`UpdatePlanRegistrationsInPeriod` :394) + +**Interfaces:** +- Consumes: `DayLockHelper.*`, resx keys. +- Produces: no new public API. + +**Why both layers:** the interceptor guarantees correctness but surfaces as an exception. These guards turn the common, user-triggered cases into a clean message. The recalculation paths get the opposite treatment — they *skip* rather than throw, because a dashboard load legitimately spans the boundary on every single request. + +- [ ] **Step 1: Guard `TimePlanningPlanningService.Update`** + +Immediately after the `planning == null` check (currently ends :700), insert: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (DayLockHelper.IsLocked(lockedThrough, planning.Date)) + { + return new OperationResult(false, localizationService.GetString( + planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + } +``` + +- [ ] **Step 2: Guard `UpdateByCurrentUserNam`** + +Same block, immediately after that method's own null/lookup check for the planning row. + +- [ ] **Step 3: Guard the three working-hours write paths** + +In `TimePlanningWorkingHoursService.CreateUpdate` (:454), after the assigned-site lookup and before the per-day loop writes, reject the whole request if any posted day is locked: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); + if (lockedThrough.HasValue + && model.Plannings.Any(p => DayLockHelper.IsLocked(lockedThrough, p.Date))) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } +``` + +The two `UpdateWorkingHour` overloads need **different** code — their signatures differ, and neither matches a naive copy: + +**Personal overload (`:1339`)** — there is no `sdkSiteId` variable in this method. The site comes from the SDK site looked up at `:1381-1383`, so place the guard after that lookup: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync( + dbContext, (int)sdkSite.MicrotingUid!); + if (DayLockHelper.IsLocked(lockedThrough, model.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } +``` + +**Kiosk overload (`:2012`)** — `sdkSiteId` is a parameter and is **`int?`**. It has no assigned-site lookup near the top; the lookup is duplicated deep inside two branches (`:2308-2310` and `:2591-2593`), so guarding "after the lookup" would protect one branch and leave the other open. Guard at the **top of the method**, from the parameter, before either branch: + +```csharp + if (sdkSiteId.HasValue) + { + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, sdkSiteId.Value); + if (DayLockHelper.IsLocked(lockedThrough, model.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + } +``` + +The kiosk overload currently has **no date guard of any kind** — this is its first. Note neither overload wraps its body in try/catch (only `CreateUpdate` does, at `:456`), so a guard that throws would surface raw. + +- [ ] **Step 4: Skip locked days in the bulk recompute** + +`UpdatePlanRegistrationsInPeriod` has exactly **one** loop — `foreach (var plan in planningsInPeriod)` at `PlanRegistrationHelper.cs:423`, whose body runs from `:424` to `:1138`. That single body both **writes** (four `Update` calls) and **projects** (`:862` builds the DTO, `:1137` adds it to the row). + +**Do NOT change what the loop iterates.** Swapping the source to a filtered list would drop locked days out of the grid entirely, which contradicts spec §6.3 — locked days must still be *displayed*, just never rewritten. That is a silent wrong result, not a build error, so it would ship. + +Instead: resolve the boundary **once before** the loop, and guard **only the four writes**. + +Before `foreach (var plan in planningsInPeriod)` at `:423`: + +```csharp + // A dashboard load legitimately spans the boundary, so locked days are + // skipped rather than raising. Without this, the interceptor turns + // every visit to a closed month into a 500. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); +``` + +Immediately after `planRegistration` is materialised at `:425`: + +```csharp + var dayIsLocked = DayLockHelper.IsLocked(lockedThrough, planRegistration.Date); +``` + +Then wrap **each** of the four `await planRegistration.Update(dbContext)` calls — at `:471`, `:542`, `:810` and `:834` — as: + +```csharp + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } +``` + +Everything from `:862` down is untouched, so the projection is preserved by construction. + +**Also note `:1109`:** `planningsInPeriod` is *reassigned inside the loop* (a re-query feeding the totals at `:1127-1135`). The already-taken enumerator is unaffected, but do not add any other logic that depends on that collection's identity. + +- [ ] **Step 5: Stop gap-fill inside a locked range** + +In `TimePlanningPlanningService.Index` (loop at **:370-402**) and `IndexByCurrentUserName` (loop at **:593-623**), skip missing dates that fall inside the lock. + +**Use the right context and the right site.** `Index`'s gap-fill writes through `innerDbContext`, created per site at `:284-285` inside the `foreach (var assignedSite in assignedSites)` at `:216` — resolve `lockedThrough` inside that per-site block from `innerDbContext` and `dbAssignedSite.SiteId`, never before the outer loop (that would resolve it for the wrong worker). `IndexByCurrentUserName`'s gap-fill uses `dbContext` and has a single site, so it is simpler. + +Then in each loop: + +```csharp + if (DayLockHelper.IsLocked(lockedThrough, missingDate)) + { + // Frozen means frozen: a locked period does not grow new rows. + continue; + } +``` + +with `lockedThrough` resolved once before the loop. + +- [ ] **Step 6: Add tests for the skipping behaviour** + +Append to `ReconcileServiceTests.cs`: + +```csharp + [Test] + public async Task Index_OverALockedRange_LeavesLockedRowsByteIdentical() + { + var row = await SeedPlain(910, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }); + + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version), + "a no-op re-save still bumps Version — this catches silent rewrites"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + }); + } + + [Test] + public async Task Index_OverALockedRange_CreatesNoNewRows() + { + var row = await SeedPlain(911, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + var countBefore = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 911); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }); + + var countAfter = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 911); + Assert.That(countAfter, Is.EqualTo(countBefore), + "gap-fill must not materialise rows inside a frozen period"); + } +``` + +- [ ] **Step 7: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +git commit -m "fix(lock): return a message on blocked writes and skip locked days when recalculating" +``` + +--- + +## Task 6: Expose the lock state on the read model + +**Files:** +- Modify: `.../Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs:235-237` +- Modify: `.../Infrastructure/Models/Planning/TimePlanningPlanningModel.cs:78` +- Modify: `.../Infrastructure/Helpers/PlanRegistrationHelper.cs:862` (projection) and where `siteModel` is built +- Test: append to `ReconcileServiceTests.cs` + +**Interfaces:** +- Produces (consumed by Tasks 7-11): + - `TimePlanningPlanningPrDayModel.Reconciled : bool` + - `TimePlanningPlanningPrDayModel.ReconciledAt : DateTime?` + - `TimePlanningPlanningModel.LockedThrough : DateTime?` + +- [ ] **Step 1: Add the per-day fields** + +In `TimePlanningPlanningPrDayModel.cs`, after `NettoHoursOverrideActive` (:236): + +```csharp + /// True only on the boundary day itself. Earlier days are locked + /// by derivation and keep this false — see LockedThrough on the row. + public bool Reconciled { get; set; } + + public DateTime? ReconciledAt { get; set; } +``` + +- [ ] **Step 2: Add the row field** + +In `TimePlanningPlanningModel.cs`, before `PlanningPrDayModels` (:78): + +```csharp + /// The worker's reconciled boundary: every day at or before this is + /// locked. Null when nothing is reconciled. Sent once per row so the client + /// compares dates instead of scanning cells. + public DateTime? LockedThrough { get; set; } +``` + +- [ ] **Step 3: Populate them** + +In `PlanRegistrationHelper.cs` at the `planningModel` initializer (:862), add: + +```csharp + Reconciled = planRegistration.Reconciled, + ReconciledAt = planRegistration.ReconciledAt, +``` + +And set the row-level value **once, BEFORE the loop at `:423`** — reuse the `lockedThrough` local that Task 5 Step 4 already resolves there: + +```csharp + siteModel.LockedThrough = lockedThrough; +``` + +**Not** alongside the totals at `:1131-1135`: those are inside the loop, so that would re-query once per day per site, and — worse — a worker with **no rows in the window never enters the loop at all**, leaving `LockedThrough` null. A worker whose entire visible month is locked would then render as fully editable. + +- [ ] **Step 4: Test the projection** + +```csharp + [Test] + public async Task Index_ProjectsReconciledStateOntoTheReadModel() + { + var row = await SeedPlain(912, DateTime.Now.Date.AddDays(-4)); + await _service.Reconcile(row.Id); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + var result = await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-6), + DateTo = DateTime.Now.Date + }); + + var siteRow = result.Model.Single(x => x.SiteId == 912); + var day = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == row.Date.Date); + Assert.Multiple(() => + { + Assert.That(siteRow.LockedThrough, Is.EqualTo(row.Date), + "the client needs the boundary once per row, not per cell"); + Assert.That(day.Reconciled, Is.True); + Assert.That(day.ReconciledAt, Is.Not.Null); + }); + } +``` + +- [ ] **Step 5: Build and commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +git commit -m "feat(lock): send the reconciled state and boundary to the client" +``` + +**→ Natural phase boundary. The backend is complete and enforceable here. Open a PR, watch CI green, and merge before starting Task 7 if you want to land this incrementally.** + +--- + +## Task 7: The interceptor in the service repo + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-service-timeplanning-plugin` +- Create: `ServiceTimePlanningPlugin/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` (copy of Task 2's, namespace changed) +- Create: `ServiceTimePlanningPlugin/Infrastructure/Helpers/DayLockHelper.cs` (copy of Task 1's, namespace changed) +- Modify: `ServiceTimePlanningPlugin/Infrastructure/Helpers/DbContextHelper.cs:39-44` + +**Why duplicated rather than shared:** the two repos share no code except the base NuGet, and this design adds nothing to that package. Duplicating ~90 lines is the smaller cost. **A divergence between the copies is a lock with a hole**, so both files carry a header comment naming their twin. + +- [ ] **Step 1: Copy the helper and interceptor** + +Copy both files, change `namespace TimePlanning.Pn.Infrastructure.*` to `namespace ServiceTimePlanningPlugin.Infrastructure.*`, and add at the top of each: + +```csharp +// NOTE: this is a deliberate copy of the same file in +// eform-angular-timeplanning-plugin (TimePlanning.Pn/Infrastructure/...). +// The two repos share only the base NuGet package, which this design does not +// modify. If you change one, change the other: a divergence means background +// jobs can write days the web refuses to, which is worse than no lock at all. +``` + +- [ ] **Step 2: Wire it into the service helper** + +Replace `DbContextHelper.GetDbContext()`: + +```csharp + public TimePlanningPnDbContext GetDbContext() + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Hardcoded version, exactly as TimePlanningPnContextFactory does + // (factory line 39). ServerVersion.AutoDetect OPENS A CONNECTION and + // runs a version query; this method is called once per assigned site on + // every dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); + + return new TimePlanningPnDbContext(optionsBuilder.Options); + } +``` + +This covers `eFormCompletedHandler`, `SearchListJob` (including its nightly soft-**delete** path), `FlexChainCatchUpJob` and `Core.cs:257`. + +**One documented exception.** `Core.cs:146-148` builds a context straight from +`TimePlanningPnContextFactory`, bypassing this helper, so it is **not** guarded. That +context is read-only in practice — migrations plus reading `PluginConfigurationValues` +at `:170`/`:174` — and writes no `PlanRegistration`. Leave it, but add a comment at +`:146` saying so, because the spec's own risk table calls an unguarded construction +site "a lock with a hole … worse than no lock", and the next reader deserves to know +this one was considered rather than missed. + +- [ ] **Step 3: Add the enforcement test this repo requires** + +Spec §10 requires *"an explicit test in each repo asserting a locked write is rejected +through that repo's own context construction"* — otherwise a divergence between the two +copies is invisible. The service repo has `ServiceTimePlanningPlugin.Integration.Test` +(NUnit 4.6.1 + Testcontainers MariaDb) and, unlike the plugin repo, **no shard +allowlist**, so a new class runs automatically. + +Create `ServiceTimePlanningPlugin.Integration.Test/DayLockInterceptorTests.cs` with one +test proving the wiring — build the context through `DbContextHelper.GetDbContext()`, +not by hand, or it proves nothing: + +```csharp + [Test] + public async Task AWriteToALockedDay_IsRejected_ThroughThisReposOwnHelper() + { + var helper = new DbContextHelper(ConnectionString); + await using var db = helper.GetDbContext(); + + var earlier = new PlanRegistration + { + SdkSitId = 950, Date = new DateTime(2026, 1, 13), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await earlier.Create(db); + + await new PlanRegistration + { + SdkSitId = 950, Date = new DateTime(2026, 1, 16), + Reconciled = true, ReconciledAt = DateTime.Now, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }.Create(db); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => await earlier.Update(db), + "background jobs must be as locked out as the web is -- if this fails, the " + + "two copies of the interceptor have diverged"); + } +``` + +- [ ] **Step 4: Build and commit (in the service repo)** + +```bash +cd /home/rene/Documents/workspace/microting/eform-service-timeplanning-plugin +dotnet build -v q +git add ServiceTimePlanningPlugin/Infrastructure/ +git commit -m "feat(lock): enforce the reconciled-day lock in background jobs" +``` + +--- + +## Task 8: Frontend models and service methods + +**Files:** +- Modify: `eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts` +- Modify: `.../models/plannings/time-planning.model.ts` +- Modify: `.../services/time-planning-pn-plannings.service.ts` + +**Interfaces:** +- Consumes: the three DTO fields from Task 6. +- Produces: + - `PlanningPrDayModel.reconciled: boolean`, `.reconciledAt: string | null` + - `TimePlanningModel.lockedThrough: string | null` + - `TimePlanningPnPlanningsService.reconcileDay(id: number)`, `.unreconcileDay(id: number)`, `.reconcileThrough(date: string, siteIds: number[])` + +- [ ] **Step 1: Add the model fields** + +In `planning-pr-day.model.ts`, before the closing brace: + +```ts + /** True only on the boundary day. Earlier days are locked by derivation. */ + reconciled: boolean; + reconciledAt: string | null; +``` + +In `time-planning.model.ts`, before the closing brace: + +```ts + /** Every day at or before this is locked for this worker. Null = nothing locked. */ + lockedThrough: string | null; +``` + +- [ ] **Step 2: Add the service methods** + +In `time-planning-pn-plannings.service.ts`, inside the class: + +```ts + reconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/reconcile', {} + ); + } + + unreconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/unreconcile', {} + ); + } + + reconcileThrough( + date: string, siteIds: number[] + ): Observable> { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/reconcile-through', + {date, siteIds} + ); + } +``` + +Create `models/plannings/reconcile-through-result.model.ts`: + +```ts +export class ReconcileThroughResultModel { + landedOn: string | null; + applied: number; + skippedSiteIds: number[]; +} +``` + +and export it from `models/plannings/index.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/models/ \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "feat(lock): add the lock fields and reconcile calls to the client" +``` + +--- + +## Task 9: Three states in the grid + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` (`getCellClass` :201-245, `onDayColumnClick` :458-485) + +**Interfaces:** +- Consumes: `TimePlanningModel.lockedThrough`, `PlanningPrDayModel.reconciled` (Task 8); CSS classes from Task 10. +- Produces: `isDayLocked(row, field) -> boolean`, `isDayReconciled(row, field) -> boolean`. + +**The signature problem:** `getCellClass` returns a **single** string today and mtx-grid stamps it onto the ``. Three lock states layer *on top of* the existing four backgrounds, so it must return composed classes. + +- [ ] **Step 1: Add the predicates and compose the classes** + +Add to the class: + +```ts + /** The worker's boundary, parsed once per call. Null when nothing is locked. */ + private lockedThrough(row: any): number | null { + return row?.lockedThrough ? new Date(row.lockedThrough).setHours(0, 0, 0, 0) : null; + } + + isDayLocked(row: any, field: string): boolean { + const boundary = this.lockedThrough(row); + const date = row?.planningPrDayModels?.[field]?.date; + if (boundary === null || !date) { + return false; + } + return new Date(date).setHours(0, 0, 0, 0) <= boundary; + } + + isDayReconciled(row: any, field: string): boolean { + return row?.planningPrDayModels?.[field]?.reconciled === true; + } +``` + +Then change `getCellClass` to append a lock class to whatever it already returns. Replace the method's `return` statements with a single composed exit by wrapping the existing body: + +```ts + getCellClass(row: any, field: string): string { + const base = this.getCellStateClass(row, field); + if (this.isDayReconciled(row, field)) { + return `${base} reconciled-background`; + } + if (this.isDayLocked(row, field)) { + return `${base} locked-background`; + } + return base; + } +``` + +and rename the existing `getCellClass` body (lines 201-245) to `private getCellStateClass(row: any, field: string): string` with no other change. The four existing background classes keep working untouched. + +- [ ] **Step 2: Open locked days read-only** + +In `onDayColumnClick` (:458), pass the lock state into the dialog so it can render read-only — the dialog still **opens**, because people read closed days constantly: + +```ts + this.dialog.open(WorkdayEntityDialogComponent, { + data: { + planningPrDayModels: cellData, + assignedSiteModel: result.model, + tags: row.tags ?? [], + isLocked: this.isDayLocked(row, field), + isReconciled: this.isDayReconciled(row, field), + lockedThrough: row.lockedThrough ?? null, + }, + ... +``` + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts +git commit -m "feat(lock): mark locked and reconciled days in the grid" +``` + +--- + +## Task 10: The cell styles (HOST REPO — separate PR) + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-angular-frontend` +- Modify: `eform-client/src/scss/styles.scss` (append after the `.red-background .plan-container` block ending :358) + +Per CLAUDE.md, **all SCSS lives in `eform-angular-frontend`** — this is a second repo, a second branch and a second PR. The four existing `*-background` classes live here (`:196`, `:239`, `:280`, `:320`) and the new ones must sit beside them. + +- [ ] **Step 1: Add the two state classes** + +Mirroring the exact selector shape of the existing four (bare class, then a `.plan-container` descendant, `!important` throughout, `var(--token, #fallback)`): + +```scss +/* Reconciled / locked day cells. + Four independent channels carry the state — texture, glyph, cursor and the + tooltip text — so colour is never load-bearing. The 3px right border on a + reconciled cell is what draws the boundary line down the grid. + Theme-agnostic on purpose: body.theme-eform rules do not apply under + theme-workspace, and these must read on both. */ +:root { + --tp-locked-bg: #E4E7E4; + --tp-locked-hatch: rgba(22, 33, 30, 0.055); + --tp-seal-ink: #2F5D50; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --tp-locked-bg: #262B29; + --tp-locked-hatch: rgba(230, 234, 232, 0.06); + --tp-seal-ink: #7FD1B9; + } +} + +.locked-background .plan-container { + background: repeating-linear-gradient(135deg, + var(--tp-locked-hatch) 0 2px, transparent 2px 6px), + var(--tp-locked-bg) !important; + /* beats the shared rule `.plan-container, .progress-container` (selector at + styles.scss:360, declaration :361). Scoping by .locked-background means the + avatar/progress circle is unaffected. */ + cursor: not-allowed !important; +} + +.locked-background .plan-content { + opacity: 0.72; +} + +.reconciled-background .plan-container { + background: var(--tp-locked-bg) !important; + border-right: 3px solid var(--tp-seal-ink) !important; + cursor: default !important; +} +``` + +- [ ] **Step 2: Commit in the host repo** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-frontend +git checkout -b feat/reconciled-day-lock-styles +git add eform-client/src/scss/styles.scss +git commit -m "feat(timeplanning): add locked and reconciled day-cell styles" +``` + +Open a PR against that repo's target branch. Note from project memory: three FOSSA checks fail on every `eform-angular-frontend` PR and are non-gating. + +--- + +## Task 11: Read-only dialog, reconcile and unlock + +**Files:** +- Modify: `.../workday-entity/workday-entity-dialog.component.ts` (data type :50-54, `isInTheFuture` assignment :249, the `updateDisabledStates()` cascade :865-1075) +- Modify: `.../workday-entity/workday-entity-dialog.component.html` (hint ~:461, actions :494-514) + +**Interfaces:** +- Consumes: the dialog data fields added in Task 9; service methods from Task 8. +- Produces: nothing downstream. + +**The trap — and check the premise, because a wrong one invites skipping the step.** `updateDisabledStates()` (`:865-1075`) makes 74 `setDisabled` calls, 38 of them enabling. Each enable *is* value-guarded (`if (isSet(p1Start))`, `if (thirdShiftActive)`…). What none of them is guarded by is **`isInTheFuture`** — so the cascade already silently re-enables controls the constructor disabled for future dates. That is a pre-existing bug, and a day-lock implemented only at construction inherits it: one keystroke reopens a locked form. Guarding inside `setDisabled` fixes both at once. + +- [ ] **Step 1: Accept the new data fields** + +Extend the injected data type at `:50-54`: + +```ts + public data = inject<{ + planningPrDayModels: PlanningPrDayModel, + assignedSiteModel: AssignedSiteModel, + tags?: SharedTagModel[], + isLocked?: boolean, + isReconciled?: boolean, + lockedThrough?: string | null + }>(MAT_DIALOG_DATA); +``` + +Add a field and set it beside the `isInTheFuture` assignment at `:249`: + +```ts + /** A reconciled or cascade-locked day opens read-only. */ + isLocked = false; +``` + +```ts + this.isLocked = this.data.isLocked === true; +``` + +- [ ] **Step 2: Make the cascade honour the lock** + +This is the load-bearing change. Modify `setDisabled` itself (`:481-492`; note `getCtrl` already exists at `:477-479` and `setDisabled` already calls it — you are changing one line inside an existing method, not introducing either) so no caller can re-enable a control on a locked day: + +```ts + private setDisabled(path: string, disabled: boolean) { + const c = this.getCtrl(path); + if (!c) { + return; + } + // A locked day can never re-enable a control. The progressive-enable + // cascade calls setDisabled(path, false) unconditionally in many branches; + // without this guard, touching any field would reopen the form. + const effective = disabled || this.isLocked; + if (effective && c.enabled) { + c.disable({emitEvent: false}); + } + if (!effective && c.disabled) { + c.enable({emitEvent: false}); + } + } +``` + +Then disable the whole form once, after the form is built: + +```ts + if (this.isLocked) { + this.workdayForm.disable({emitEvent: false}); + } +``` + +- [ ] **Step 3: Add the banner** + +In the .html, beside the existing future hint (`:461-465`): + +```html + +``` + +- [ ] **Step 4: Replace the actions on a locked day** + +In `mat-dialog-actions` (:494-514), hide Save and offer the reverse action. Note `[mat-dialog-close]="data"` fires regardless of `(click)`, so Save must be removed from the DOM, not merely disabled: + +```html + + + + + +``` + +- [ ] **Step 5: Add the handlers** + +```ts + /** I2: today and future days stay open so time can still be registered. */ + get canReconcile(): boolean { + const d = new Date(this.data.planningPrDayModels.date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + d.setHours(0, 0, 0, 0); + return d < today; + } + + onReconcile(): void { + this.planningsService.reconcileDay(this.data.planningPrDayModels.id) + .subscribe(result => { + if (result && result.success) { + this.dialogRef.close(this.data); + } + }); + } + + onUnlock(): void { + this.planningsService.unreconcileDay(this.data.planningPrDayModels.id) + .subscribe(result => { + if (result && result.success) { + this.dialogRef.close(this.data); + } + }); + } +``` + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/ +git commit -m "feat(lock): open locked days read-only, with reconcile and unlock" +``` + +--- + +## Task 12: Bulk reconcile in the toolbar + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.{ts,html}` (grid selection) +- Modify: `.../time-plannings-container/time-plannings-container.component.{ts,html}` (toolbar + scope bar) + +**Interfaces:** +- Consumes: `reconcileThrough` (Task 8). +- Produces: nothing downstream. + +**Two mtx-grid gotchas, both documented in the host's backend-configuration task-list and both guaranteed to bite here:** +1. mtx-grid binds `(click)="_selectRow()"` on the ``. With `[rowSelectable]`, clicking a **day cell** clears the batch selection. The cell must call `stopRowClick($event)`. +2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` **without emitting** `rowSelectedChange`, so the component must re-emit an empty selection itself. + +- [ ] **Step 1: Enable row selection** + +In the table .html, on `` (:19-30): + +```html + [rowSelectable]="true" + [multiSelectable]="true" + (rowSelectedChange)="onRowSelected($event)" +``` + +In the table .ts: + +```ts + @Output() selectionChanged: EventEmitter = new EventEmitter(); + + onRowSelected(rows: any[]): void { + this.selectionChanged.emit((rows ?? []).map(r => r.siteId)); + } + + /** mtx-grid selects the row on any click inside it; the day cell must not. */ + stopRowClick(event: Event): void { + event.stopPropagation(); + } +``` + +In the day-cell template (:238), add `(click)="stopRowClick($event)"` to the `.plan-container` **before** the existing handler so the row-select is suppressed but the dialog still opens: + +```html +
+``` + +And re-emit an empty selection in `ngOnChanges` when `timePlannings` changes, because the grid will not: + +```ts + if (changes.timePlannings) { + this.selectionChanged.emit([]); + } +``` + +- [ ] **Step 2: Add the toolbar control** + +In the container .html, after the reload button (`:100-108`): + +```html + + +``` + +- [ ] **Step 3: Add the container logic** + +```ts + selectedSiteIds: number[] = []; + reconcileThroughDate: string | null = null; + + /** I2: nothing at or after today may be reconciled. */ + get maxReconcileDate(): string { + const d = new Date(); + d.setDate(d.getDate() - 1); + return d.toISOString().slice(0, 10); + } + + onSelectionChanged(siteIds: number[]): void { + this.selectedSiteIds = siteIds; + } + + onReconcileThrough(): void { + if (!this.reconcileThroughDate) { + return; + } + // No selection means every worker currently visible under the active + // filters — the month-end case. + const siteIds = this.selectedSiteIds.length + ? this.selectedSiteIds + : this.timePlannings.map(x => x.siteId); + + this.planningsService.reconcileThrough(this.reconcileThroughDate, siteIds) + .subscribe(result => { + if (result && result.success) { + this.reconcileThroughDate = null; + this.selectedSiteIds = []; + this.getPlannings(); + } + }); + } +``` + +Bind the table's output in the container template: `(selectionChanged)="onSelectionChanged($event)"`. + +- [ ] **Step 4: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/ +git commit -m "feat(lock): reconcile many workers through a date from the toolbar" +``` + +--- + +## Task 13: Help entries and translations + +**Files:** +- Modify: `.../help/help.model.ts` (the `HELP_IDS` array) +- Modify: `.../help/planning-help.registry.ts` +- Modify: `.../help/i18n/da.ts` and `.../help/i18n/enUS.ts` +- Modify: `.../i18n/da.ts` and `.../i18n/enUS.ts` + +`HelpEntryId` is a closed union off `HELP_IDS`, and prose is `Record` — adding an id **forces** both help i18n files to gain the entry or the build fails. + +- [ ] **Step 1: Add the ids** + +In `help/help.model.ts`, add to `HELP_IDS` in the day-cell block: `'dayCell.reconciled'`, `'dayCell.lockedByReconciled'`, and in the toolbar block: `'toolbar.reconcileThrough'`. + +- [ ] **Step 2: Register the entries** + +In `help/planning-help.registry.ts`, in the toolbar block (after `:44`): + +```ts + { id: 'toolbar.reconcileThrough', kind: 'control', section: 'toolbar', anchor: 'toolbar.reconcileThrough' }, +``` + +in the day-cell block: + +```ts + { id: 'dayCell.reconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.reconciled' }, + { id: 'dayCell.lockedByReconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.lockedByReconciled' }, +``` + +Do **not** assign a `tour` step — page tour steps 1-8 are taken and the tour order is deliberate. + +- [ ] **Step 3: Add the prose (Danish)** + +In `help/i18n/da.ts`: + +```ts + 'dayCell.reconciled': { + title: 'Afstemt', + short: 'Dagens tal er endelige.', + detail: 'Dagen er afstemt, og tallene ændres ikke længere — heller ikke af en efterberegning. Alle dage før denne er samtidig låst.', + keywords: ['afstemt', 'låst', 'endelig', 'afslutning'], + }, + 'dayCell.lockedByReconciled': { + title: 'Låst', + short: 'Dagen ligger før en afstemt dag.', + detail: 'Dagen kan ikke ændres, fordi en senere dag er afstemt. Lås den seneste afstemte dag op først.', + keywords: ['låst', 'afstemt', 'tidligere'], + }, + 'toolbar.reconcileThrough': { + title: 'Afstem til og med', + short: 'Sæt grænsen for flere medarbejdere på én gang.', + detail: 'Vælg en dato. Er ingen rækker markeret, gælder den alle synlige medarbejdere. Kun grænsedagen markeres som afstemt — alt før låses automatisk.', + keywords: ['afstem', 'flere', 'månedsafslutning', 'lås'], + }, +``` + +- [ ] **Step 4: Add the prose (English)** + +Same three keys in `help/i18n/enUS.ts` with English values matching the copy rule — describe what the day *is*, never who may change it. + +- [ ] **Step 5: Add the UI strings** + +In `i18n/da.ts`, before the closing `};`: + +```ts + 'Reconcile day': 'Afstem dag', + 'Reconcile through': 'Afstem til og med', + Unlock: 'Lås op', + Reconciled: 'Afstemt', + Locked: 'Låst', +``` + +In `i18n/enUS.ts`, the same keys with identity values. + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help/ \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n/ +git commit -m "feat(lock): add help entries and translations for the day lock" +``` + +--- + +## Task 14: End-to-end test + +**Files:** +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/` seed files (copy `a/`'s) +- Modify: `.github/workflows/dotnet-core-pr.yml:95` and `dotnet-core-master.yml:102` matrix — add `s` + +**Anchor rows by worker identity, never by grid index.** The `#cellN_M` ids are positional; a row shift silently addresses a different worker. Read the worker from the dialog title and assert it stays constant, as `e1m/dashboard-edit-multishift.spec.ts` now does. + +- [ ] **Step 1: Write the spec** + +```ts +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +async function dialogWorker(page: Page): Promise { + const title = page.locator('mat-dialog-container [mat-dialog-title]'); + await expect(title).toBeVisible({ timeout: 10000 }); + const raw = (await title.innerText()).replace(/\s+/g, ' ').trim(); + return raw.split(/\s+-\s+/)[0].trim(); +} + +test.describe('Reconciled day lock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + }); + + test('reconciling a day locks it and every earlier day for that worker', async ({ page }) => { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const indexPromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await indexPromise; + await waitForSpinner(page); + + // Open a past day and reconcile it. + await page.locator('#cell3_1').click(); + const worker = await dialogWorker(page); + const reconcilePromise = page.waitForResponse(r => + r.url().includes('/reconcile') && r.request().method() === 'PUT'); + await page.locator('#reconcileButton').click(); + await reconcilePromise; + await waitForSpinner(page); + + // The cell now carries the boundary treatment. + await expect(page.locator('#cell3_1').locator('..')) + .toHaveClass(/reconciled-background/); + + // The day before it is locked, but NOT reconciled. + await expect(page.locator('#cell3_0').locator('..')) + .toHaveClass(/locked-background/); + + // Opening the locked day gives a read-only dialog for the same worker. + await page.locator('#cell3_0').click(); + expect(await dialogWorker(page)).toBe(worker); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await page.locator('#cancelButton').click(); + + // The boundary day offers unlock; using it frees both days. + await page.locator('#cell3_1').click(); + expect(await dialogWorker(page)).toBe(worker); + const unlockPromise = page.waitForResponse(r => + r.url().includes('/unreconcile') && r.request().method() === 'PUT'); + await page.locator('#unlockButton').click(); + await unlockPromise; + await waitForSpinner(page); + + await expect(page.locator('#cell3_0').locator('..')) + .not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 2: Add the shard** + +Add `s` to the `matrix.test` array in `.github/workflows/dotnet-core-pr.yml:95` and `dotnet-core-master.yml:102`. + +**Do not copy seed SQL.** The seed step (`dotnet-core-pr.yml:156-171`) falls back to `a/`'s dumps when a shard has none — `b`, `c`, `p`, `q` and `r` already rely on that, and copying the 19 MB `420_SDK.sql` plus the 3.2 MB plugin dump would be 22 MB for nothing. What *is* required: the `s/` directory must contain at least one spec, because the test step runs `npx playwright test .../${{ matrix.test }}/` and exits non-zero when it finds none. + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/ \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "test(lock): end-to-end reconcile, cascade and unlock" +``` + +--- + +## Task 15: Open the PR and watch CI + +- [ ] **Step 1: Dual review gate (MANDATORY before the PR)** + +Dispatch `superpowers:requesting-code-review` **and** a `code-simplifier` subagent in parallel. Act on the findings; do not merge around them. + +- [ ] **Step 2: Push and open the PR** + +```bash +git push -u origin feat/reconciled-day-lock +gh pr create --base stable --title "feat(lock): reconciled ('Afstemt') day lock" --body "" +``` + +- [ ] **Step 3: Watch CI to a verdict** + +`gh pr checks ` until every check has finished. For each red check, find the failed step and classify it as infrastructure or a real failure. Compare against `stable`'s latest run — a shard also red there is not yours. + +Two known flake sources in this repo, neither caused by this change: +- `b/activate-plugin.spec.ts:30` has a hardcoded `waitForTimeout(100000)` inside a 180s budget; it runs in setup for every `1m` shard. +- MariaDB container start fails intermittently. If a job dies at "Start MariaDB" with every test step skipped, that shard ran nothing — re-run it. + +Re-running a failed job **overwrites** its conclusion, so a green run can hide a first-attempt failure. To audit honestly: `gh api repos///actions/runs//attempts/1/jobs`. + +- [ ] **Step 4: Report the verdict, do not merge without approval** + +--- + +## Self-Review + +**Spec coverage.** §4 data model → Task 1. §4.2 I1 → Tasks 4 (write) and 1 (test). I2 → Tasks 1, 4, 12. I3 → Task 2. §4.3 query cost → Task 1 (`LockedThroughForSitesAsync`). §5 write inventory → Tasks 2, 5, 7. §6.1 three layers → Tasks 2 (L1), 5 (L2, L3). §6.2 cascades → Global Constraints + Task 1 doc comment. §6.3 gap-fill → Task 5 Step 5. §6.4 timezone → Task 1 `CanReconcile`. §7 API → Task 4; read model → Task 6. §8.1 three states → Tasks 9, 10. §8.2 single day → Task 11. §8.3 bulk → Tasks 4 (`ReconcileThrough`), 12. §8.4 unlock → Tasks 4, 11. §8.5 blocked feedback → Task 11. §8.6 permissions → no admin gate anywhere (verified: no `[Authorize]` added in Task 4). §9 testing → Tasks 1, 2, 4, 5, 6, 14. + +**Gaps found and closed while reviewing:** +- The interceptor must permit the unlock write on the boundary day, or unlocking would be blocked by the lock it removes. Added `IsUnlockOfBoundaryDay` (Task 2 Step 3) and a test for it. +- `TestBaseSetup` builds its own contexts and would bypass the interceptor — the lock would appear tested while being unenforced. Added Task 2 Step 6. +- `[mat-dialog-close]="data"` fires regardless of `(click)`, so a disabled Save still closes with data. Save is removed from the DOM on a locked day, not disabled (Task 11 Step 4). + +**Type consistency.** `lockedThrough` is `DateTime?` in C# and `string | null` in TS (JSON). `LockedThroughAsync` / `LockedThroughForSitesAsync` / `IsLocked` / `CanReconcile` are used with those exact names in Tasks 2, 4, 5, 6. `reconciled` / `reconciledAt` match the C# `Reconciled` / `ReconciledAt` under default camelCase JSON. CSS classes `locked-background` / `reconciled-background` match between Task 9 (emitted) and Tasks 10, 14 (styled, asserted). + +**Known risk carried forward:** Task 7 duplicates ~90 lines into the service repo. A divergence is a lock with a hole. Both copies carry a header comment naming the twin; there is no shared package to put it in without modifying the base. From 56ca9d401bedb64444fde17e3aa9c5d2ffa52c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sun, 13 Sep 2026 15:33:54 +0200 Subject: [PATCH 04/18] docs: park the Reconciled day lock with a pick-up-here status Design and plan are complete and reviewed; no implementation code exists. Records where the branch is, the one decision still open (spec 8.1-8.4 UI requirements have no tasks -- particularly the typed unlock confirmation, which is the only safeguard on a feature any web user can trigger), what the design settled so it is not re-litigated, and the traps the plan review already caught so they are not reintroduced. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-13-STATUS-reconciled-day-lock.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md diff --git a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md new file mode 100644 index 00000000..b0d3a8a3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md @@ -0,0 +1,106 @@ +# STATUS — Reconciled ("Afstemt") day lock — PARKED 2026-09-13 + +**State:** design and plan complete and reviewed. **No implementation code written.** + +**Branch:** `docs/reconciled-day-lock-spec` — 3 commits, **never pushed, no PR**, working tree clean. + +``` +da5cd018 docs: implementation plan for the Reconciled day lock +145cd2cb docs: settle bulk scope, payroll independence and mobile handling +8f2f9fdf docs: design for the Reconciled ("Afstemt") day lock +``` + +**Artefacts** +- Spec: `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md` +- Plan: `docs/superpowers/plans/2026-09-12-reconciled-day-lock.md` (15 tasks, 3 repos) +- Interactive mockups: https://claude.ai/code/artifact/30122702-dd2e-4826-b7f3-ee03c6ae218d + +--- + +## To pick this up + +1. `git checkout docs/reconciled-day-lock-spec` +2. Read the spec, then the plan. The plan's Global Constraints section carries every + project-wide rule; each task is self-contained from there. +3. Decide the one open question below. +4. Push the docs branch and PR it, or fold the docs into the implementation branch. +5. Execute with `superpowers:subagent-driven-development` — one fresh subagent per task, + review between tasks. + +**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks 7-14 +(service repo, frontend, styles, e2e) can land separately. + +--- + +## THE OPEN DECISION — needed before Task 9 + +Spec §8.1–8.4 requirements with **no task in the plan**. Either write tasks for them or +move them to non-goals explicitly; do not let them lapse silently. + +| Spec ref | Requirement | Why it matters | +|---|---|---| +| §8.1 | `lock` / `verified` glyphs, tooltips, the legend under the grid | Three of four visual channels survive without them, so "never colour alone" still holds — but nobody learns what the hatch means without the legend | +| §8.2 | Two-step inline confirm in the dialog footer; provenance line after | Reconciling is near-irreversible and any web user can do it | +| §8.3 | Preview of the affected region before commit; confirm naming worker count and landing date | The cascade is the part people get wrong in their heads | +| §8.4 | **Typed-word confirmation to unlock** | This is the *only* safeguard on a feature any web user can trigger. Dropping it is a real weakening, not a trim | + +Recommendation: keep §8.4 at minimum. + +--- + +## What the design settled (so it is not re-litigated) + +- **No base-repo change.** `Reconciled` / `ReconciledAt` already exist on + `PlanRegistrations` and `PlanRegistrationVersions` (migration `20260127060748`) and ship + in the pinned `Microting.TimePlanningBase` **10.0.62**. Nothing writes them today. +- **The lock is derived, never stored:** `lockedThrough(site) = MAX(Date) WHERE Reconciled`. + "Earlier days locked but not marked" and "unlock only in reverse order" fall out of the + model rather than needing enforcement. +- **Scope:** per worker per day. Bulk = one date × a set of workers (selected rows, a + selected column, or all visible). +- **Permissions:** any web user may reconcile. No admin gate. +- **I2 — nothing at or after today may be reconciled** — is load-bearing beyond the + obvious: it is what makes the forward flex cascades (one 180 days ahead, one unbounded) + provably unable to reach a locked day. **Do not relax it without revisiting them.** +- **No payroll link.** `Reconciled` and `TransferredToPayroll` are independent both ways. +- **Mobile** rejects the write; no mobile UI. +- Enforcement is a `SaveChangesInterceptor` (complete, unbypassable) + friendly guards on + user-facing paths + silent skipping on recalculation paths. Guards alone would repeat the + `MaxDaysEditable` mistake, which is enforced only on the read path and in the browser and + is bypassable by a crafted POST today. + +## Traps the plan review already caught — do not reintroduce + +- **Never call `ServerVersion.AutoDetect` in the interceptor or in `GetDbContext()`.** It + opens a connection and runs a version query; both run per-save / per-site on every + dashboard load. The factory hardcodes `new MariaDbServerVersion(new Version(10, 5, 0))` — + match it. +- **Do not filter the collection in `UpdatePlanRegistrationsInPeriod`.** Its single + 715-line loop fuses writing and projection; filtering it drops locked days out of the + grid. Guard the four `Update` calls instead. +- **Set `siteModel.LockedThrough` before the loop**, or a worker with no rows in the + window renders as fully editable. +- **`TestBaseSetup` builds its own contexts** — attach the interceptor there or every + enforcement test passes through an unguarded context. +- **`PnBase.Create` overwrites `WorkflowState`**; `PnBase.Delete` is a *soft* delete + arriving as `Modified`, never `Deleted`. +- **No Pomelo.** This repo uses the `Microting.EntityFrameworkCore.MySql` fork. +- The interceptor must **permit** clearing the flag on the boundary day, or unlocking is + blocked by the lock it removes. + +--- + +## Unrelated items parked alongside + +- **Column M (Saturday hours)** in the Excel export still reads bare `NettoHours`, ignoring + the override — the same bug fixed for column J in PR #1704. On `stable` now. +- **Overridden Grundlovsdag** ignores the override, because an override carries no + information about which hours fell after noon. A payroll policy decision, documented at + the call site. +- **`b/activate-plugin.spec.ts:30`** has a hardcoded `waitForTimeout(100000)` inside a 180s + budget, in the setup path for all nine `1m` shards. Highest-leverage flakiness fix + available — one change covers nine shards. +- **Flakiest Playwright shards** measured over 40 runs (recovering failures that re-runs had + masked): `k` 5, `b` 3, `r` 3. `e1m` was 1. +- **The host-app mirror is stale** (Aug 14). `devgetchanges.sh` against it would overwrite + recent work with old code. From 5536a8f44e6944770cac7d29d0b253dc8e9f9350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 07:03:18 +0200 Subject: [PATCH 05/18] docs: make the reconcile status doc a full handoff entry point Renamed STATUS -> HANDOFF and folded in what a cold session needs that was previously only in chat: the dev-mode gate, the normal development cycle including the dual review gate, subagent-driven execution, the CI-only testing rule and the shard-filter trap, and a note that stable has moved and that the dependency-alignment map shares this branch by accident. One entry point instead of a status note plus a separate brief. Co-Authored-By: Claude Opus 5 (1M context) --- ...2026-09-13-HANDOFF-reconciled-day-lock.md} | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) rename docs/superpowers/plans/{2026-09-13-STATUS-reconciled-day-lock.md => 2026-09-13-HANDOFF-reconciled-day-lock.md} (65%) diff --git a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md similarity index 65% rename from docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md rename to docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md index b0d3a8a3..e7898404 100644 --- a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md @@ -1,4 +1,6 @@ -# STATUS — Reconciled ("Afstemt") day lock — PARKED 2026-09-13 +# HANDOFF — Reconciled ("Afstemt") day lock + +**Parked 2026-09-13. This file is the entry point — read it before the spec or the plan.** **State:** design and plan complete and reviewed. **No implementation code written.** @@ -19,18 +21,51 @@ da5cd018 docs: implementation plan for the Reconciled day lock ## To pick this up -1. `git checkout docs/reconciled-day-lock-spec` -2. Read the spec, then the plan. The plan's Global Constraints section carries every - project-wide rule; each task is self-contained from there. -3. Decide the one open question below. -4. Push the docs branch and PR it, or fold the docs into the implementation branch. -5. Execute with `superpowers:subagent-driven-development` — one fresh subagent per task, - review between tasks. - -**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks 7-14 -(service repo, frontend, styles, e2e) can land separately. - ---- +**Announce the dev-mode gate first** (CLAUDE.md requires it), then: + +1. Read this file, then the spec, then the plan. The plan's Global Constraints section + carries every project-wide rule; each task is self-contained from there. +2. Settle the open decision below with the user. +3. Decide whether to push this docs branch and PR it, or fold the docs into the + implementation branch. Note the dependency-alignment map (`3f33a08e`) rode along on the + same branch and is unrelated work — split it out if this becomes a PR. +4. `stable` has moved since parking (`9e2ae84f`, `82348e71`, `1ef699be` — none touch the + lock). Rebase or merge before starting. +5. Execute with **`superpowers:subagent-driven-development`** — one fresh subagent per + task, review between tasks. + +**Follow the normal development cycle for every task** (CLAUDE.md): branch off `stable`, +write or update tests, verify what can be verified locally (`dotnet build` only — see +below), run the **dual review gate** (`superpowers:requesting-code-review` AND a +`code-simplifier` subagent, dispatched in parallel) before committing, stage files by +name, PR into `stable`, then watch CI to a verdict. + +**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks +7-14 (service repo, frontend, styles, e2e) can land separately. + +### What this feature does + +Marking a worker's day **Afstemt** freezes that day and every earlier day for that worker +against web edits, mobile registrations and background recalculation. Earlier days lock +**without** being marked `Reconciled`. A day can only be unlocked once every day after it +is unlocked — like a Chinese ring puzzle. + +### House rules that bite here + +- **Dev mode: NONE — edit the source repos directly.** Do **not** run `devgetchanges.sh`; + the host-app mirror is stale (Aug 14) and syncing from it would overwrite recent work. +- **Tests run ONLY in CI.** Never run `dotnet test`, `playwright test`, `jest` or + `npm test` locally — a PreToolUse hook blocks them. `dotnet build` is allowed and + expected. Push and watch `gh pr checks `. +- **New C# test classes must be added to the shard filters in BOTH** + `.github/workflows/dotnet-core-pr.yml` **and** `dotnet-core-master.yml`, or they + silently never run. +- **Never commit to `stable`.** Branch, PR in. +- **SCSS lives in `eform-angular-frontend`**, never per-plugin — Task 10 is a separate + repo and a separate PR. Expect three FOSSA checks to fail on that repo's PRs; they are + non-gating. +- **User-facing copy must never explain a restriction by referring to what an + administrator may do** ("admin = Microting"). State what the day *is*. ## THE OPEN DECISION — needed before Task 9 From 09bca5420022d0a16d2e10aa36ccc0920ca0c614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 07:28:06 +0200 Subject: [PATCH 06/18] feat(lock): derive the reconciled-day boundary from the data The lock is derived, never stored: a worker's boundary is MAX(Date) over their non-removed Reconciled PlanRegistration rows. Every day at or before that boundary is locked. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../DayLockHelperTests.cs | 177 ++++++++++++++++++ .../Infrastructure/Helpers/DayLockHelper.cs | 86 +++++++++ 4 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index fc183975..6214cab1 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 8975ac27..07858004 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs new file mode 100644 index 00000000..961f7c7b --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The lock is derived, never stored: a day is locked when the worker has any +/// Reconciled day at or after it. These tests pin that derivation, because +/// every enforcement layer downstream trusts it. +/// +[TestFixture] +public class DayLockHelperTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + // No workflowState parameter: PnBase.Create overwrites it with "created" + // regardless of what is set here. + private async Task Seed(int site, DateTime date, bool reconciled) + { + await new PlanRegistrationEntity + { + SdkSitId = site, + Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + + [Test] + public async Task LockedThrough_NoReconciledDay_IsNull() + { + await Seed(700, new DateTime(2026, 1, 12), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 700); + + Assert.That(result, Is.Null, "a worker with nothing reconciled has no boundary"); + } + + [Test] + public async Task LockedThrough_SeveralReconciled_IsTheLatest() + { + await Seed(701, new DateTime(2026, 1, 12), reconciled: true); + await Seed(701, new DateTime(2026, 1, 16), reconciled: true); + await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 16))); + } + + [Test] + public async Task LockedThrough_IgnoresRemovedRows() + { + // Once Task 2's interceptor exists, any write that touches a + // PlanRegistration at or before the current boundary (I3) is + // rejected. That rules out the obvious seeding -- reconcile a row, + // then soft-delete it in a LATER, separate save -- because by the + // time of that second save the boundary would already be the row + // being deleted, and the delete would land ON the boundary day. + // + // Instead: seed 702/12 reconciled (this is and stays the boundary), + // and seed 702/18 PLAIN (not reconciled, so it holds no boundary and + // simply sits above the existing one). Then, in a SINGLE save on the + // tracked 18th entity, set Reconciled + ReconciledAt and soft-delete + // it via Delete() (PnBase.Delete only ever issues one SaveChanges + // when there are pending changes). At the moment that save runs, the + // DB boundary is still the 12th, so the write to the 18th is above + // the boundary and permitted -- both before Task 2's interceptor + // exists and after. The row ends up Removed, so it must never + // surface as the new boundary. + await Seed(702, new DateTime(2026, 1, 12), reconciled: true); + await Seed(702, new DateTime(2026, 1, 18), reconciled: false); + + var later = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.SdkSitId == 702 && x.Date == new DateTime(2026, 1, 18)); + later.Reconciled = true; + later.ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0); + await later.Delete(TimePlanningPnDbContext!); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 702); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 12)), + "a soft-deleted reconciled row must not hold the boundary"); + } + + [Test] + public async Task LockedThrough_IsPerWorker() + { + await Seed(703, new DateTime(2026, 1, 16), reconciled: true); + await Seed(704, new DateTime(2026, 1, 12), reconciled: false); + + // Await FIRST, then assert synchronously. Assert.Multiple(async () => ...) + // binds to the Action overload, making the lambda async void: its + // assertions can run after the block exits, and a failure is then lost + // or blamed on the wrong test. NUnit.Analyzers flags this. + var seven03 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 703); + var seven04 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 704); + + Assert.Multiple(() => + { + Assert.That(seven03, Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(seven04, Is.Null, "one worker's boundary must not leak onto another"); + }); + } + + [Test] + public async Task LockedThroughForSites_ResolvesManyInOneQuery() + { + await Seed(705, new DateTime(2026, 1, 16), reconciled: true); + await Seed(706, new DateTime(2026, 1, 13), reconciled: true); + await Seed(707, new DateTime(2026, 1, 13), reconciled: false); + + var map = await DayLockHelper.LockedThroughForSitesAsync( + TimePlanningPnDbContext!, new[] { 705, 706, 707 }); + + Assert.Multiple(() => + { + Assert.That(map[705], Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(map[706], Is.EqualTo(new DateTime(2026, 1, 13))); + Assert.That(map[707], Is.Null); + Assert.That(map.Count, Is.EqualTo(3), "every requested site gets an entry"); + }); + } + + [TestCase("2026-01-10", true, TestName = "IsLocked_BeforeBoundary_True")] + [TestCase("2026-01-16", true, TestName = "IsLocked_AtBoundary_True")] + [TestCase("2026-01-17", false, TestName = "IsLocked_AfterBoundary_False")] + public void IsLocked_RelativeToBoundary(string date, bool expectedLocked) + { + var boundary = new DateTime(2026, 1, 16); + + var locked = DayLockHelper.IsLocked(boundary, DateTime.Parse(date)); + + Assert.That(locked, Is.EqualTo(expectedLocked)); + } + + [Test] + public void IsLocked_NoBoundary_NothingIsLocked() + { + Assert.That(DayLockHelper.IsLocked(null, new DateTime(2020, 1, 1)), Is.False); + } + + [Test] + public void IsLocked_IgnoresTimeOfDay() + { + var boundary = new DateTime(2026, 1, 16); + + Assert.That(DayLockHelper.IsLocked(boundary, new DateTime(2026, 1, 16, 23, 59, 59)), + Is.True, "the boundary day is locked for its whole length"); + } + + [Test] + public void CanReconcile_TodayAndFuture_False_Past_True() + { + Assert.Multiple(() => + { + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date), Is.False, + "today must stay open so time can still be registered"); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(1)), Is.False); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(-1)), Is.True); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddHours(23)), Is.False, + "a time-of-day on today is still today"); + }); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs new file mode 100644 index 00000000..cb934187 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -0,0 +1,86 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Helpers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; + +/// +/// The single source of truth for "is this day locked". +/// +/// The lock is DERIVED, never stored. A worker's boundary is the latest date +/// they have a Reconciled registration on; every day at or before it is locked. +/// Earlier days are therefore locked WITHOUT being marked Reconciled, and a day +/// below the boundary cannot be unlocked because unlocking it would not move +/// MAX(Date) -- both requirements fall out of the model instead of needing a +/// job to keep flags in sync. +/// +public static class DayLockHelper +{ + /// + /// The latest reconciled date for one worker, or null when they have none. + /// Soft-deleted rows never hold the boundary. + /// + public static async Task LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) + { + return await BoundaryRows(db) + .Where(x => x.SdkSitId == sdkSitId) + .MaxAsync(x => (DateTime?)x.Date) + .ConfigureAwait(false); + } + + /// + /// Boundaries for many workers in ONE query. Callers that render a grid + /// resolve this once per request rather than once per day cell. + /// Every requested site gets an entry; sites with no reconciled day map to null. + /// + public static async Task> LockedThroughForSitesAsync( + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + { + var found = await BoundaryRows(db) + .Where(x => sdkSitIds.Contains(x.SdkSitId)) + .GroupBy(x => x.SdkSitId) + .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) + .ToListAsync() + .ConfigureAwait(false); + + var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); + foreach (var id in sdkSitIds) + { + map.TryAdd(id, null); + } + return map; + } + + /// + /// Pure predicate, so callers can resolve the boundary once and test many + /// dates against it without touching the database again. + /// + public static bool IsLocked(DateTime? lockedThrough, DateTime date) + => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + + /// + /// Invariant I2: today and future days must stay open so time can still be + /// registered. This is also what makes the forward flex cascades unable to + /// reach a locked day -- see the design doc before relaxing it. + /// + /// DateTime.Now, not UtcNow: PlanRegistration.Date is a local midnight, and + /// the existing mobile guard compares the same way. + /// + public static bool CanReconcile(DateTime date) => date.Date < DateTime.Now.Date; + + /// + /// What counts as a boundary row, in one place: Reconciled and not + /// soft-deleted. Both public queries compose their own site predicate + /// over this so the two never drift apart. + /// + private static IQueryable BoundaryRows(TimePlanningPnDbContext db) + => db.PlanRegistrations + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed); +} From 17bf5f2cb8edb88f809f5e7abb9b54519a65cdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:18:30 +0200 Subject: [PATCH 07/18] feat(lock): enforce the day lock at the SaveChanges boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PlanRegistration write is now checked against the worker's reconciled boundary via a SaveChangesInterceptor, on both its original and its current (site, date) slot -- so a write cannot escape the lock by moving a locked row's date past the boundary or reassigning it to another worker. Two writes remain permitted inside the locked range: clearing the flag on the boundary day itself (unlock, which also requires ReconciledAt to be cleared), and a payroll-flag-only write, which spec §11.2 requires so exporting a reconciled period keeps working. Wired into the pooled DbContext registration, TimePlanningDbContextHelper, and both test context builders in TestBaseSetup. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../DayLockHelperTests.cs | 9 +- .../DayLockInterceptorTests.cs | 267 ++++++++++++++++ .../TimePlanning.Pn.Test/TestBaseSetup.cs | 5 + .../EformTimePlanningPlugin.cs | 4 +- .../Infrastructure/Helpers/DayLockHelper.cs | 6 +- .../Helpers/TimePlanningDbContextHelper.cs | 37 ++- .../ReconciledDayLockInterceptor.cs | 287 ++++++++++++++++++ 9 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 6214cab1..44af8fe9 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 07858004..8997c9e2 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index 961f7c7b..67b21b18 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -51,9 +51,16 @@ public async Task LockedThrough_NoReconciledDay_IsNull() [Test] public async Task LockedThrough_SeveralReconciled_IsTheLatest() { + // The plain row sits ABOVE the boundary (2026-01-18, not the + // 14th): with the interceptor attached, creating an unreconciled row + // at or below an already-seeded boundary would throw during arrange, + // before this test ever got to its assertion. Seeding it above also + // strengthens the test: a plain row past the reconciled max proves + // MAX ignores non-reconciled rows, not just that it ignores earlier + // reconciled ones. await Seed(701, new DateTime(2026, 1, 12), reconciled: true); await Seed(701, new DateTime(2026, 1, 16), reconciled: true); - await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + await Seed(701, new DateTime(2026, 1, 18), reconciled: false); var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs new file mode 100644 index 00000000..08510ebf --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The interceptor is the only layer that is COMPLETE -- it covers all 32 +/// PlanRegistration write sites and anything added later. These tests go +/// through a context built exactly as production builds it (interceptor +/// attached), so they prove the wiring, not just the class. +/// +[TestFixture] +public class DayLockInterceptorTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + private async Task SeedReconciled(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + private async Task SeedPlain(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + [Test] + public async Task Modifying_ADayBelowTheBoundary_IsRejected() + { + // Order matters: create the earlier row FIRST. Seeding the boundary + // first would put this Create inside the locked range, and the arrange + // step would throw before the assertion was ever reached. + var earlier = await SeedPlain(800, new DateTime(2026, 1, 13)); + await SeedReconciled(800, new DateTime(2026, 1, 16)); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_TheBoundaryDayItself_IsRejected() + { + var boundary = await SeedReconciled(801, new DateTime(2026, 1, 16)); + + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Creating_ARowInsideALockedRange_IsRejected() + { + await SeedReconciled(802, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await SeedPlain(802, new DateTime(2026, 1, 14))); + } + + [Test] + public async Task SoftDeleting_ALockedRow_IsRejected() + { + // PnBase.Delete is a soft delete -- it sets WorkflowState = Removed via + // UpdateInternal, so this arrives at the interceptor as Modified, not + // Deleted. The name says SoftDeleting so nobody reads a pass here as + // proof that the EntityState.Deleted arm works. + var earlier = await SeedPlain(803, new DateTime(2026, 1, 13)); + await SeedReconciled(803, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await earlier.Delete(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_ADayAboveTheBoundary_IsAllowed() + { + var later = await SeedPlain(804, new DateTime(2026, 1, 18)); + await SeedReconciled(804, new DateTime(2026, 1, 16)); + + later.PlanHours = 9; + await later.Update(TimePlanningPnDbContext!); + + // AsNoTracking: reading the tracked instance back off the same context + // would prove nothing -- it already holds the in-memory value whether + // or not the write ever reached the database. + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == later.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "days after the boundary must stay fully editable"); + } + + [Test] + public async Task ANotherWorkersBoundary_DoesNotLockThisWorker() + { + var other = await SeedPlain(806, new DateTime(2026, 1, 13)); + await SeedReconciled(805, new DateTime(2026, 1, 16)); + + other.PlanHours = 9; + await other.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == other.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "the boundary is per worker; it must not leak across sites"); + } + + [Test] + public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() + { + // Unlocking must not be blocked by the very lock it is clearing. + var boundary = await SeedReconciled(807, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + await boundary.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.False, + "clearing the flag on the boundary day is how unlocking works"); + } + + [Test] + public async Task SettingTheTransferredToPayrollFlag_OnALockedDay_IsAllowed() + { + // Mirrors PayrollExportService.ExportPayroll exactly (ruling F10): + // Reconciled and TransferredToPayroll are independent, so exporting a + // reconciled period must not be blocked by the very lock reconciling + // it created. + var earlier = await SeedPlain(808, new DateTime(2026, 1, 13)); + await SeedReconciled(808, new DateTime(2026, 1, 16)); + + earlier.TransferredToPayroll = true; + earlier.TransferredToPayrollAt = DateTime.UtcNow; + await earlier.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == earlier.Id); + Assert.That(reloaded.TransferredToPayroll, Is.True, + "the payroll flag must persist on a locked day"); + } + + [Test] + public async Task ChangingHours_AlongsideThePayrollFlag_IsRejected() + { + // Proves the payroll exemption is not a loophole: as soon as anything + // else about the row changes in the same save, the lock still applies. + var earlier = await SeedPlain(809, new DateTime(2026, 1, 13)); + await SeedReconciled(809, new DateTime(2026, 1, 16)); + + earlier.TransferredToPayroll = true; + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task MovingALockedRowOutOfTheLockedRange_IsRejected() + { + // I3 is keyed on (SdkSitId, Date) at the time of the write -- it must + // also be checked against the ORIGINAL slot, or moving a locked row's + // Date to a day above the boundary would let it escape the lock it + // started inside. + var earlier = await SeedPlain(810, new DateTime(2026, 1, 13)); + await SeedReconciled(810, new DateTime(2026, 1, 16)); + + earlier.Date = new DateTime(2026, 1, 20); + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task UnlockingAndChangingHours_OnTheBoundaryDay_IsRejected() + { + // Proves the unlock exemption is not a loophole either: as soon as + // anything besides Reconciled/ReconciledAt changes in the same save, + // the lock still applies -- even on the boundary day itself. + var boundary = await SeedReconciled(811, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task ClearingReconciled_BelowTheBoundary_IsRejected() + { + // Unlocking only ever works ON the boundary day itself -- a day + // below it cannot be unlocked, because clearing its flag would not + // move MAX(Date) (see DayLockHelper's doc comment): the row would + // still sit below the (unchanged) boundary set by the later + // reconciled day. + var earlier = await SeedReconciled(812, new DateTime(2026, 1, 12)); + await SeedReconciled(812, new DateTime(2026, 1, 16)); + + earlier.Reconciled = false; + earlier.ReconciledAt = null; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task MovingALockedRowToAnotherWorker_IsRejected() + { + // Same escape as MovingALockedRowOutOfTheLockedRange_IsRejected, but + // via SdkSitId instead of Date: without checking the ORIGINAL site, + // reassigning a locked row to a worker with no boundary of their own + // would let it through. + var earlier = await SeedPlain(814, new DateTime(2026, 1, 13)); + await SeedReconciled(814, new DateTime(2026, 1, 16)); + + earlier.SdkSitId = 815; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task ClearingReconciledButKeepingReconciledAt_OnTheBoundaryDay_IsRejected() + { + // Guards invariant I1 at the choke point: Reconciled=false with + // ReconciledAt still set is not a valid unlock, even on the boundary + // day itself -- the row must never end up in a state I1 forbids. + var boundary = await SeedReconciled(816, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 022e9288..365089de 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -10,6 +10,7 @@ using NUnit.Framework; using Testcontainers.MariaDb; using TimePlanning.Pn.Infrastructure.Data.Seed; +using TimePlanning.Pn.Infrastructure.Interceptors; #nullable enable namespace TimePlanning.Pn.Test; @@ -39,6 +40,8 @@ private TimePlanningPnDbContext GetTimePlanningPnDbContext(string connectionStr) builder.EnableRetryOnFailure(); }); + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + var backendConfigurationPnDbContext = new TimePlanningPnDbContext(optionsBuilder.Options); // Drop and recreate the database fresh for each test to avoid state pollution @@ -119,6 +122,8 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() builder.EnableRetryOnFailure(); }); + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + return new TimePlanningPnDbContext(optionsBuilder.Options); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index cefa56a8..697eacf6 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -27,6 +27,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.EformAngularFrontendBase.Infrastructure.Data; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Services.TimePlanningRegistrationDeviceService; using TimePlanning.Pn.Services.TimePlanningGpsCoordinateService; using TimePlanning.Pn.Services.TimePlanningPictureSnapshotService; @@ -186,7 +187,8 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr { builder.EnableRetryOnFailure(); builder.MigrationsAssembly(PluginAssembly().FullName); - })); + }) + .AddInterceptors(ReconciledDayLockInterceptor.Instance)); var contextFactory = new TimePlanningPnContextFactory(); var context = contextFactory.CreateDbContext(new[] { connectionString }); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index cb934187..33ed43f5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -4,6 +4,7 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microting.eForm.Infrastructure.Constants; @@ -40,13 +41,14 @@ public static class DayLockHelper /// Every requested site gets an entry; sites with no reconciled day map to null. /// public static async Task> LockedThroughForSitesAsync( - TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds, + CancellationToken cancellationToken = default) { var found = await BoundaryRows(db) .Where(x => sdkSitIds.Contains(x.SdkSitId)) .GroupBy(x => x.SdkSitId) .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) - .ToListAsync() + .ToListAsync(cancellationToken) .ConfigureAwait(false); var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs index b73b8f4f..1d25e435 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs @@ -1,21 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore; using Microting.TimePlanningBase.Infrastructure.Data; -using Microting.TimePlanningBase.Infrastructure.Data.Factories; +using TimePlanning.Pn.Infrastructure.Interceptors; +// NB: no Pomelo using. This repo uses the Microting.EntityFrameworkCore.MySql +// fork, and MariaDbServerVersion/ServerVersion come from +// Microsoft.EntityFrameworkCore -- which is why EformTimePlanningPlugin.cs +// needs no provider-specific using either. Adding a Pomelo PackageReference +// would introduce a second, conflicting provider. namespace TimePlanning.Pn.Infrastructure.Helpers; +/// +/// Builds plugin DbContexts with the day-lock interceptor attached. +/// +/// This no longer delegates to TimePlanningPnContextFactory: that factory +/// builds its DbContextOptionsBuilder in a method-local and exposes no hook, so +/// there is no way to attach an interceptor through it. The context's public +/// options constructor is the supported seam, and it needs no base-package change. +/// public class TimePlanningDbContextHelper(string connectionString) : ITimePlanningDbContextHelper { - private string ConnectionString { get;} = connectionString; + private string ConnectionString { get; } = connectionString; public TimePlanningPnDbContext GetDbContext() { - TimePlanningPnContextFactory contextFactory = new TimePlanningPnContextFactory(); + var optionsBuilder = new DbContextOptionsBuilder(); - return contextFactory.CreateDbContext([ConnectionString]); + // Hardcoded version, exactly as TimePlanningPnContextFactory does. + // ServerVersion.AutoDetect OPENS A CONNECTION and runs a version + // query; this method is called once per assigned site on every + // dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + + return new TimePlanningPnDbContext(optionsBuilder.Options); } } public interface ITimePlanningDbContextHelper { TimePlanningPnDbContext GetDbContext(); -} \ No newline at end of file +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs new file mode 100644 index 00000000..591eaf99 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs @@ -0,0 +1,287 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Interceptors; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Helpers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microting.TimePlanningBase.Infrastructure.Data; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +/// +/// Thrown when a write would touch a day at or before the worker's reconciled +/// boundary. Distinct from a generic InvalidOperationException so the friendly +/// guards in the services can tell "the lock stopped this" from "something else +/// broke" -- and so a stray one is legible in Sentry. +/// +/// After this is thrown, the rejected entry stays tracked on the DbContext (EF +/// never rolls back change-tracker state just because SaveChanges threw). A +/// later SaveChanges call on the SAME context will see that entry again and +/// rethrow. Callers that catch this and want to keep using the context must +/// either stop trying to save through it, or explicitly detach/reload the +/// rejected entry first. +/// +public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThrough) + : InvalidOperationException( + $"Day {date:yyyy-MM-dd} for site {sdkSitId} is locked: reconciled through {lockedThrough:yyyy-MM-dd}.") +{ + public int SdkSitId { get; } = sdkSitId; + public DateTime Date { get; } = date; + public DateTime LockedThrough { get; } = lockedThrough; +} + +/// +/// Enforces invariant I3 across every PlanRegistration write path. +/// +/// STATELESS BY DESIGN. The pooled context registration +/// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an +/// interceptor holding per-request state would leak it between requests. +/// Everything this needs is read from the change tracker on each call. Since +/// there is no state, one shared is used everywhere +/// instead of a `new` per registration. +/// +/// The boundary is resolved ONCE per SaveChanges for the distinct sites in the +/// change set, not once per row. +/// +public class ReconciledDayLockInterceptor : SaveChangesInterceptor +{ + public static readonly ReconciledDayLockInterceptor Instance = new(); + + /// + /// Bookkeeping columns PnBase.Update/Delete touch on every single save + /// (UpdateInternal bumps Version and UpdatedAt unconditionally, and + /// callers routinely stamp UpdatedByUserId), regardless of what the + /// caller actually meant to change. Both permitted-write checks below + /// need "what did the caller actually change", so both exclude these. + /// + private static readonly HashSet BookkeepingProperties = + [ + nameof(PlanRegistrationEntity.UpdatedAt), + nameof(PlanRegistrationEntity.Version), + nameof(PlanRegistrationEntity.UpdatedByUserId), + ]; + + private static readonly HashSet UnlockProperties = + [ + nameof(PlanRegistrationEntity.Reconciled), + nameof(PlanRegistrationEntity.ReconciledAt), + ]; + + /// + /// Spec §11.2: Reconciled and TransferredToPayroll are independent in both + /// directions -- reconciling a period must not affect export eligibility, + /// and exporting it must not be blocked by the lock reconciling it + /// created. PayrollExportService.ExportPayroll sets exactly these two + /// properties, one Update per row, after the export file already exists; + /// without this exemption exporting any reconciled period throws midway + /// and leaves some rows flagged and some not. Do not "tighten" this away. + /// + private static readonly HashSet PayrollFlagProperties = + [ + nameof(PlanRegistrationEntity.TransferredToPayroll), + nameof(PlanRegistrationEntity.TransferredToPayrollAt), + ]; + + /// One (SdkSitId, Date) pair a write touches -- see . + private readonly record struct Slot(int SdkSitId, DateTime Date); + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + // Sync-over-async is safe here: ASP.NET Core requests run with no + // SynchronizationContext, and GuardAsync/LockedThroughForSitesAsync use + // ConfigureAwait(false) throughout, so there is no continuation to + // deadlock on. This path exists only because the plugin's own seed + // code calls the synchronous SaveChanges; those seeds never touch + // PlanRegistration, so GuardAsync returns before awaiting anything. + GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + await GuardAsync(eventData.Context, cancellationToken).ConfigureAwait(false); + return await base.SavingChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task GuardAsync(DbContext? context, CancellationToken cancellationToken) + { + if (context is not TimePlanningPnDbContext db) + { + return; + } + + // PnBase.Delete is a SOFT delete: it sets WorkflowState = Removed and + // routes through UpdateInternal, so a delete reaches here as Modified, + // never Deleted. The Deleted arm is kept as insurance against a future + // hard delete; do not "simplify" it away on the grounds that it never + // fires today. + // + // Each entry contributes one or two slots: I3 is keyed on + // (SdkSitId, Date), and a Modified/Deleted row can change either or + // both -- moving a row's Date past the boundary, or its SdkSitId to + // another worker, must not let it escape a lock it started inside. + var entrySlots = db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .ToDictionary(e => e, SlotsToCheck); + + if (entrySlots.Count == 0) + { + return; + } + + var siteIds = entrySlots.Values + .SelectMany(slots => slots) + .Select(s => s.SdkSitId) + .Distinct() + .ToList(); + + // Query `db` itself. An earlier draft opened a second context here; + // that is a production outage, because ServerVersion.AutoDetect opens a + // connection and runs a version query, and this guard runs on EVERY + // save -- including the four Update calls inside the per-day loop of + // UpdatePlanRegistrationsInPeriod, which runs on every dashboard load. + // 50 workers x 31 days would mean thousands of extra connections per + // page view. + // + // Querying `db` is safe: LockedThroughForSitesAsync projects into an + // anonymous type so it tracks nothing and cannot pollute the change + // tracker; a query never re-enters SaveChanges so there is no + // recursion; and it reuses the open connection and any ambient + // transaction, which a separate context could not see. + var boundaries = await DayLockHelper + .LockedThroughForSitesAsync(db, siteIds, cancellationToken) + .ConfigureAwait(false); + + foreach (var (entry, slots) in entrySlots) + { + Slot? lockedSlot = null; + DateTime lockedBoundary = default; + + foreach (var slot in slots) + { + if (boundaries.TryGetValue(slot.SdkSitId, out var boundary) + && DayLockHelper.IsLocked(boundary, slot.Date)) + { + lockedSlot = slot; + lockedBoundary = boundary!.Value; + break; + } + } + + if (lockedSlot is null) + { + continue; + } + + // The two permitted writes inside the locked range: clearing the + // flag on the boundary day itself (that is what unlocking IS), and + // a payroll-flag-only write on any locked day (ruling F10). Both + // are evaluated against the entry's CURRENT site's boundary -- + // that is the only boundary either exemption is ever about -- and + // both already fail whenever Date or SdkSitId is among the + // modified properties (they are not in either exemption's allowed + // property set), which is exactly what stops a locked ORIGINAL + // slot from being exempted just because the write is moving the + // row away from it. + var currentSiteBoundary = boundaries.TryGetValue(entry.Entity.SdkSitId, out var csb) ? csb : null; + if ((currentSiteBoundary is not null && IsUnlockOfBoundaryDay(entry, currentSiteBoundary.Value)) + || IsPayrollFlagOnlyWrite(entry)) + { + continue; + } + + throw new DayLockedException(lockedSlot.Value.SdkSitId, lockedSlot.Value.Date, lockedBoundary); + } + } + + /// + /// The slot(s) I3 must check for one entry: always the CURRENT + /// (SdkSitId, Date), and -- for Modified/Deleted only, since Added has no + /// "before" -- the ORIGINAL one too, when it differs. Never reads + /// OriginalValue on an Added entry; EF has no original value to give one. + /// + private static List SlotsToCheck(EntityEntry entry) + { + var current = new Slot(entry.Entity.SdkSitId, entry.Entity.Date); + + if (entry.State is not (EntityState.Modified or EntityState.Deleted)) + { + return [current]; + } + + var original = new Slot( + entry.Property(x => x.SdkSitId).OriginalValue, + entry.Property(x => x.Date).OriginalValue); + + return original.Equals(current) ? [current] : [current, original]; + } + + /// + /// What the caller actually changed, ignoring the bookkeeping columns + /// every PnBase.Update/Delete call touches regardless of intent. Both + /// permitted-write predicates below are just a subset check over this. + /// + private static HashSet NonBookkeepingModifiedProperties( + EntityEntry entry) + => entry.Properties + .Where(p => p.IsModified) + .Select(p => p.Metadata.Name) + .Where(name => !BookkeepingProperties.Contains(name)) + .ToHashSet(); + + private static bool IsUnlockOfBoundaryDay( + EntityEntry entry, DateTime boundary) + { + if (entry.State != EntityState.Modified) + { + return false; + } + if (entry.Entity.Date.Date != boundary.Date) + { + return false; + } + // Reconciled must be going true -> false, and nothing else about the + // row -- besides Reconciled/ReconciledAt themselves -- may be + // changing in the same save. + var reconciled = entry.Property(x => x.Reconciled); + if (!reconciled.IsModified || (bool)reconciled.CurrentValue!) + { + return false; + } + // Invariant I1: ReconciledAt non-null exactly when Reconciled. A + // "clear Reconciled but leave ReconciledAt set" write is not a valid + // unlock -- it would leave the row in a state I1 forbids -- so this + // choke point refuses it rather than trusting every caller to clear + // both together. + if (entry.Property(x => x.ReconciledAt).CurrentValue is not null) + { + return false; + } + + return NonBookkeepingModifiedProperties(entry).IsSubsetOf(UnlockProperties); + } + + private static bool IsPayrollFlagOnlyWrite(EntityEntry entry) + { + if (entry.State != EntityState.Modified) + { + return false; + } + + var changed = NonBookkeepingModifiedProperties(entry); + // changed.Count == 0 would mean nothing but bookkeeping moved -- not a + // payroll write, and Count > 0 here already guarantees at least one of + // TransferredToPayroll/TransferredToPayrollAt is the modified property + // once the subset check below passes. + return changed.Count > 0 && changed.IsSubsetOf(PayrollFlagProperties); + } +} From 4a845325f23521b52e4fb391e81ac99ef5e916b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:22:40 +0200 Subject: [PATCH 08/18] feat(lock): add the day-lock messages in English and Danish Co-Authored-By: Claude Opus 5 --- .../Resources/Translations.Designer.cs | 36 +++++++++++++++++++ .../Resources/Translations.da.resx | 18 ++++++++++ .../Resources/Translations.resx | 18 ++++++++++ 3 files changed, 72 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index 8d2fea37..a9854710 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -308,5 +308,41 @@ internal static string DayOverview { return ResourceManager.GetString("DayOverview", resourceCulture); } } + + internal static string DayIsReconciled { + get { + return ResourceManager.GetString("DayIsReconciled", resourceCulture); + } + } + + internal static string DayIsLockedByReconciledDay { + get { + return ResourceManager.GetString("DayIsLockedByReconciledDay", resourceCulture); + } + } + + internal static string CannotReconcileTodayOrFuture { + get { + return ResourceManager.GetString("CannotReconcileTodayOrFuture", resourceCulture); + } + } + + internal static string OnlyLatestReconciledDayCanBeUnlocked { + get { + return ResourceManager.GetString("OnlyLatestReconciledDayCanBeUnlocked", resourceCulture); + } + } + + internal static string SuccessfullyReconciledDay { + get { + return ResourceManager.GetString("SuccessfullyReconciledDay", resourceCulture); + } + } + + internal static string SuccessfullyUnlockedDay { + get { + return ResourceManager.GetString("SuccessfullyUnlockedDay", resourceCulture); + } + } } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index f2ec3ac5..d4887ddb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -201,4 +201,22 @@ Dagsoversigt + + Dagen er afstemt. Tallene er endelige. + + + Dagen er låst, fordi den ligger før en afstemt dag. + + + Kun dage før i dag kan afstemmes. + + + Lås den seneste afstemte dag op først. + + + Dagen er afstemt + + + Dagen er låst op + \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index 1b1bf136..eaae564f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -201,4 +201,22 @@ Day overview + + This day is reconciled. The figures are final. + + + This day is locked because it is before a reconciled day. + + + Only days before today can be reconciled. + + + Unlock the most recent reconciled day first. + + + Day reconciled + + + Day unlocked + \ No newline at end of file From 2d0e30df1fc16c85bf59148bbf242927f01fd625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:48:43 +0200 Subject: [PATCH 09/18] feat(lock): add reconcile, unlock and reconcile-through endpoints Reconcile rejects today and later days (I2) and is idempotent; unlock is allowed only on the worker's boundary day; reconcile-through marks one landing day per worker and reports skips by reason; a concurrent boundary move degrades to a per-site skip. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../ReconcileServiceTests.cs | 368 ++++++++++++++++++ .../TimePlanningPlanningController.cs | 22 ++ .../Planning/ReconcileThroughRequestModel.cs | 15 + .../Planning/ReconcileThroughResultModel.cs | 27 ++ .../ITimePlanningPlanningService.cs | 4 + .../TimePlanningPlanningService.cs | 233 +++++++++++ 8 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 44af8fe9..60789b59 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 8997c9e2..ca6dc05f 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs new file mode 100644 index 00000000..fb4b63ad --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.Planning; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningPlanningService; + +namespace TimePlanning.Pn.Test; + +[TestFixture] +public class ReconcileServiceTests : TestBaseSetup +{ + private ITimePlanningPlanningService _service; + private IUserService _userService; + private ITimePlanningLocalizationService _localizationService; + private IEFormCoreService _coreService; + private ITimePlanningDbContextHelper _dbContextHelper; + private IPluginDbOptions _options; + + [SetUp] + public async Task SetUpTest() + { + await base.Setup(); + + _userService = Substitute.For(); + _userService.UserId.Returns(1); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = 1 }); + + _localizationService = Substitute.For(); + _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + _coreService = Substitute.For(); + var core = await GetCore(); + _coreService.GetCore().Returns(core); + + _dbContextHelper = Substitute.For(); + // NB: this hands out the SHARED fixture context. TimePlanningPlanningService + // .Index() disposes every context it takes from the helper, so a test that + // calls Index() through this stub would dispose the fixture out from under + // itself. The Index() tests below go through BuildAdminIndexServiceAsync, + // which overrides this with a fresh context per call -- do the same for any + // new one. + _dbContextHelper.GetDbContext().Returns(TimePlanningPnDbContext); + + _options = Substitute.For>(); + _options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + _service = new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext, + _dbContextHelper, + _userService, + _localizationService, + null, + _coreService); + } + + /// + /// One plain PlanRegistration. Returns the tracked entity so tests can + /// reconcile it by Id. Note PnBase.Create forces WorkflowState to "created". + /// + private async Task SeedPlain(int siteId, DateTime date) + { + var row = new PlanRegistration + { + SdkSitId = siteId, + Date = date, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + /// + /// Builds a TimePlanningPlanningService wired for the full Index() path: + /// real BaseDbContext seeded with an admin user (role "admin"), and an + /// ITimePlanningDbContextHelper that hands out a FRESH plugin context per + /// call — Index() fans out per-site work concurrently, and production's + /// helper also returns a new context per call. + /// + /// Not used by this task's own tests, but copied verbatim so Tasks 5 and 6 + /// can append Index() tests to this fixture without duplicating it. + /// + private async Task BuildAdminIndexServiceAsync(BaseDbContext baseDbContext) + { + var role = new EformRole { Name = "admin", NormalizedName = "ADMIN" }; + baseDbContext.Roles.Add(role); + await baseDbContext.SaveChangesAsync(); + + var user = new EformUser + { + UserName = "admin@planning-index.test", + Email = "admin@planning-index.test", + FirstName = "Admin", + LastName = "PlanningIndex" + }; + baseDbContext.Users.Add(user); + await baseDbContext.SaveChangesAsync(); + + baseDbContext.UserRoles.Add(new EformUserRole { UserId = user.Id, RoleId = role.Id }); + await baseDbContext.SaveChangesAsync(); + + _userService.UserId.Returns(user.Id); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); + _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); + + return new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext, + _dbContextHelper, + _userService, + _localizationService, + baseDbContext, + _coreService); + } + + [Test] + public async Task Reconcile_APastDay_SetsFlagAndTimestamp() + { + var row = await SeedPlain(900, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.True); + Assert.That(reloaded.ReconciledAt, Is.Not.Null, "I1: the timestamp is written with the flag"); + }); + } + + [Test] + public async Task Reconcile_Today_IsRejected() + { + var row = await SeedPlain(901, DateTime.Now.Date); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture"), + "I2: today must stay open so time can still be registered"); + } + + [Test] + public async Task Reconcile_AFutureDay_IsRejected() + { + var row = await SeedPlain(902, DateTime.Now.Date.AddDays(3)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture")); + } + + [Test] + public async Task Reconcile_AnAlreadyReconciledDay_IsAnIdempotentSuccess() + { + var row = await SeedPlain(903, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var again = await _service.Reconcile(row.Id); + + Assert.That(again.Success, Is.True, "re-reconciling is a no-op, not an error"); + } + + [Test] + public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay() + { + var earlier = await SeedPlain(904, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(904, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), + "the puzzle rule: free the outermost piece first"); + } + + [Test] + public async Task Unreconcile_AtTheBoundary_MovesItBackToTheNextNewest() + { + var earlier = await SeedPlain(905, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(905, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(later.Id); + + Assert.That(result.Success, Is.True, result.Message); + var boundary = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 905); + Assert.That(boundary, Is.EqualTo(earlier.Date), + "the boundary moves back one notch, it does not vanish"); + } + + [Test] + public async Task Unreconcile_ClearsBothFlagAndTimestamp() + { + var row = await SeedPlain(909, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var result = await _service.Unreconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.False); + Assert.That(reloaded.ReconciledAt, Is.Null, "I1: unlock clears both together"); + }); + } + + [Test] + public async Task Reconcile_ADayBelowAnExistingBoundary_IsRejectedWithAMessage() + { + var later = await SeedPlain(910, DateTime.Now.Date.AddDays(-3)); + var earlier = await SeedPlain(910, DateTime.Now.Date.AddDays(-8)); + await _service.Reconcile(later.Id); + + var result = await _service.Reconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay"), + "the explicit Layer-2 IsLocked guard rejects this with a message before any write is attempted"); + } + + [Test] + public async Task Reconcile_DoesNotTouchTransferredToPayroll() + { + // false is TransferredToPayroll's default, so seeding it false would + // pass even if Reconcile blindly reset the flag. Seed it TRUE (and + // stamp TransferredToPayrollAt) so the assertion actually catches a + // reconcile that resets an already-exported flag -- the real §11.2 risk. + var row = await SeedPlain(911, DateTime.Now.Date.AddDays(-5)); + row.TransferredToPayroll = true; + row.TransferredToPayrollAt = DateTime.Now; + await row.Update(TimePlanningPnDbContext!); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.TransferredToPayroll, Is.True, + "spec 11.2: Reconciled and TransferredToPayroll are independent"); + Assert.That(reloaded.TransferredToPayrollAt, Is.Not.Null, + "spec 11.2: reconciling must not clear the export timestamp either"); + }); + } + + [Test] + public async Task ReconcileThrough_MarksOneDayPerWorker_AndSkipsThoseAlreadyFurtherForward() + { + var aEarly = await SeedPlain(906, DateTime.Now.Date.AddDays(-6)); + var bEarly = await SeedPlain(907, DateTime.Now.Date.AddDays(-6)); + var bLate = await SeedPlain(907, DateTime.Now.Date.AddDays(-2)); + await _service.Reconcile(bLate.Id); // 907's boundary is already newer + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 906, 907 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.Applied, Is.EqualTo(1)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.EquivalentTo(new[] { 907 }), + "moving 907 backwards would be an unlock, which is a separate action"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty, + "907 was skipped for the other reason -- the two must not be conflated"); + Assert.That(result.Model.LandedOnBySiteId[906], Is.EqualTo(aEarly.Date)); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(907), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_LandsOnTheLatestDayWithARegistration() + { + await SeedPlain(908, DateTime.Now.Date.AddDays(-9)); + // nothing on -8 or -7 + var target = DateTime.Now.Date.AddDays(-7); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = target, SiteIds = new List { 908 } + }); + + Assert.That(result.Model.LandedOnBySiteId[908], Is.EqualTo(DateTime.Now.Date.AddDays(-9)), + "a seal on a day with no registration would mean nothing"); + } + + [Test] + public async Task ReconcileThrough_SkipsWorkersWithNoRegistration() + { + // Site 912 has no PlanRegistration rows at all. + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 912 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.SkippedNoRegistration, Is.EquivalentTo(new[] { 912 })); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.Empty, + "no registration is a distinct reason from already-further-forward"); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(912), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_ADayAlreadyReconciledOnExactlyTheLandingDay_IsANoOp() + { + var row = await SeedPlain(913, DateTime.Now.Date.AddDays(-6)); + await _service.Reconcile(row.Id); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-4), + SiteIds = new List { 913 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.AlreadyReconciledSiteIds, Is.EquivalentTo(new[] { 913 })); + Assert.That(result.Model.Applied, Is.EqualTo(0)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.Empty, + "913's only registration IS the landing day -- it must not also appear here"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty); + }); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index ef4b11fd..e1a55912 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -78,4 +78,26 @@ public async Task> GetV { return await _planningService.GetVersionHistory(planRegistrationId); } + + [HttpPut] + [Route("{id}/reconcile")] + public async Task Reconcile(int id) + { + return await _planningService.Reconcile(id); + } + + [HttpPut] + [Route("{id}/unreconcile")] + public async Task Unreconcile(int id) + { + return await _planningService.Unreconcile(id); + } + + [HttpPut] + [Route("reconcile-through")] + public async Task> ReconcileThrough( + [FromBody] ReconcileThroughRequestModel model) + { + return await _planningService.ReconcileThrough(model); + } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs new file mode 100644 index 00000000..5ede3b1f --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs @@ -0,0 +1,15 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +/// +/// One date, many workers. The cascade supplies the range, so this never +/// carries a range of its own. +/// +public class ReconcileThroughRequestModel +{ + public DateTime Date { get; set; } + public List SiteIds { get; set; } = new(); +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs new file mode 100644 index 00000000..3c7af63d --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +public class ReconcileThroughResultModel +{ + /// Where each worker's boundary landed. Per worker, not shared: + /// the mark falls on that worker's latest day with a registration at or + /// before the requested date, so a staircase has no single landing date. + public Dictionary LandedOnBySiteId { get; set; } = new(); + + /// Workers whose boundary actually moved. Excludes no-ops. + public int Applied { get; set; } + + /// Already reconciled at or past the target. Moving them back would + /// be an unlock, which is deliberately a separate, heavier action. + public List SkippedAlreadyFurtherForward { get; set; } = new(); + + /// No registration at or before the target, so there was nothing + /// to mark. A distinct case from the above — the spec distinguishes them. + public List SkippedNoRegistration { get; set; } = new(); + + /// Already marked on exactly the landing day; nothing changed. + public List AlreadyReconciledSiteIds { get; set; } = new(); +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs index d91c5ba1..f902015f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs @@ -40,4 +40,8 @@ public interface ITimePlanningPlanningService Task UpdateByCurrentUserNam(TimePlanningPlanningPrDayModel model); Task> GetVersionHistory(int planRegistrationId); + + Task Reconcile(int id); + Task Unreconcile(int id); + Task> ReconcileThrough(ReconcileThroughRequestModel model); } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 710b6d3c..2a199f53 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -29,6 +29,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Models.Settings; namespace TimePlanning.Pn.Services.TimePlanningPlanningService; @@ -2270,4 +2271,236 @@ private void CompareBoolField(List changes, string fieldName, bool? }); } } + + /// + /// Loads a live (non-removed) PlanRegistration by id. Shared by every + /// single-row reconcile mutation below; each call site still owns its own + /// null check and PlanningNotFound response. + /// + private async Task FindActivePlanningAsync(int id) + { + return await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + } + + /// + /// Sets Reconciled and ReconciledAt together and saves. I1: flag and + /// timestamp always change together. DateTime.Now, not UtcNow: the + /// tooltip renders this verbatim as "Afstemt kl. ", and UTC + /// would read 1-2 hours off in Danish time. Consistent with the + /// CanReconcile comparison. Can throw DayLockedException; callers decide + /// how to handle that race. + /// + private async Task SetReconciledAsync(PlanRegistration planning, bool reconciled) + { + planning.Reconciled = reconciled; + planning.ReconciledAt = reconciled ? DateTime.Now : null; + planning.UpdatedByUserId = userService.UserId; + await planning.Update(dbContext); + } + + public async Task Reconcile(int id) + { + try + { + var planning = await FindActivePlanningAsync(id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + + // Idempotent: re-reconciling an already reconciled day is a no-op + // success. Clients retry; that should not read as a failure. + if (planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + + if (!DayLockHelper.CanReconcile(planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Without this, reconciling a day BELOW an existing boundary passes + // CanReconcile, reaches Update, and the interceptor throws into the + // generic catch -- a 500-shaped "ErrorWhileUpdatingPlanning" instead + // of a message. That is exactly what Layer 2 exists to prevent. + var existingBoundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (DayLockHelper.IsLocked(existingBoundary, planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + + await SetReconciledAsync(planning, true); + + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + catch (DayLockedException) + { + // Expected and routine: a blocked edit is a normal outcome, not an + // incident. Do not report it to Sentry. + // + // The rejected entry stays tracked on `dbContext` (a request-scoped, + // injected context — see Task 4 amendment A1) after this throws. + // That is acceptable because we return immediately; no further + // save is attempted on this context for the rest of the request. + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Reconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task Unreconcile(int id) + { + try + { + var planning = await FindActivePlanningAsync(id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + // Boundary check FIRST. If the idempotency check came first, a user + // clicking unlock on a cascade-locked day (Reconciled = false, deep + // inside the range) would be told "Dagen er låst op" while nothing + // happened. + var boundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (boundary is null || planning.Date.Date != boundary.Value.Date) + { + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + + if (!planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + + await SetReconciledAsync(planning, false); + + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + catch (DayLockedException) + { + // Race: another request reconciled a newer day for this site + // between the boundary read above and this save, so `planning`'s + // day is no longer the boundary the interceptor will permit an + // unlock on. Routine, not an incident -- no Sentry, same as + // Reconcile's DayLockedException catch. + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Unreconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task> ReconcileThrough( + ReconcileThroughRequestModel model) + { + try + { + if (model == null || model.SiteIds.Count == 0) + { + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + if (!DayLockHelper.CanReconcile(model.Date)) + { + return new OperationDataResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Distinct: a duplicated site id in the request would otherwise be + // counted twice. + var siteIds = model.SiteIds.Distinct().ToList(); + var boundaries = await DayLockHelper.LockedThroughForSitesAsync(dbContext, siteIds); + var result = new ReconcileThroughResultModel(); + var target = model.Date.Date; + + foreach (var siteId in siteIds) + { + // Already at or past the target: moving the boundary BACK would + // be an unlock, which is deliberately a separate, heavier action. + if (DayLockHelper.IsLocked(boundaries.GetValueOrDefault(siteId), target)) + { + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + // The mark must land on a day that actually has a registration — + // a seal on an empty day means nothing. + var landing = await dbContext.PlanRegistrations + .Where(x => x.SdkSitId == siteId) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Date <= target) + .OrderByDescending(x => x.Date) + .FirstOrDefaultAsync(); + + if (landing == null) + { + // A different reason from "already further forward", and the + // spec distinguishes them -- do not merge the two lists. + result.SkippedNoRegistration.Add(siteId); + continue; + } + + if (landing.Reconciled) + { + // Already marked on exactly this day: nothing to do, and it + // must not inflate Applied. + result.AlreadyReconciledSiteIds.Add(siteId); + continue; + } + + try + { + await SetReconciledAsync(landing, true); + } + catch (DayLockedException) + { + // Race: another request moved this site's boundary between + // the snapshot taken above and this write, so `landing` is + // now at or before the NEW boundary -- the same meaning as + // "already further forward". Routine, not an incident: no + // Sentry, and the other sites in this request must not be + // aborted because of it. + // + // Detach the rejected entry: it stays tracked on this + // request-scoped `dbContext` after the throw, and every + // later site's SaveChanges on the same context would + // otherwise see it again and rethrow. + dbContext.Entry(landing).State = EntityState.Detached; + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + result.Applied++; + // Per-worker, because the boundary is a staircase: one shared + // LandedOn would name the wrong date for most workers. + result.LandedOnBySiteId[siteId] = landing.Date; + } + + return new OperationDataResult(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.ReconcileThrough failed"); + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } } \ No newline at end of file From befdf4b4c9f54100a5b699dd661384d079738613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 15:07:05 +0200 Subject: [PATCH 10/18] fix(lock): return a message on blocked writes and skip locked days when recalculating - web and mobile edits of a locked day return DayIsReconciled or DayIsLockedByReconciledDay; - the dashboard recompute reverts locked days before any save, so a closed month renders its stored values and never trips the lock; - gap-fill never creates rows inside the lock, and sends a non-persisted placeholder day instead, so the positional grid keeps one column per date; - the working-hours save skips locked rows (the page posts every row) and marks them read-only. Co-Authored-By: Claude Opus 5 --- .../DayLockHelperTests.cs | 29 + .../ReconcileServiceTests.cs | 618 +++++++++++++++++- .../Infrastructure/Helpers/DayLockHelper.cs | 21 + .../Helpers/PlanRegistrationHelper.cs | 43 +- .../TimePlanningPlanningService.cs | 86 ++- .../TimePlanningWorkingHoursService.cs | 79 ++- 6 files changed, 857 insertions(+), 19 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index 67b21b18..a64e9847 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microting.eForm.Infrastructure.Constants; @@ -181,4 +182,32 @@ public void CanReconcile_TodayAndFuture_False_Past_True() "a time-of-day on today is still today"); }); } + + /// + /// WhereOpen is the SQL-side twin of IsLocked, used by bulk writers that + /// must never load a locked row. Pins the equivalence, time of day + /// included, so the two cannot drift apart. + /// + [Test] + public void WhereOpen_KeepsExactlyTheDaysIsLockedLeavesOpen() + { + var boundary = new DateTime(2026, 1, 18); + var dates = new[] + { + boundary.AddDays(-1), + boundary, + boundary.AddHours(23).AddMinutes(59), + boundary.AddDays(1), + boundary.AddDays(1).AddHours(6) + }; + var rows = dates.Select(d => new PlanRegistrationEntity { Date = d }).AsQueryable(); + + Assert.Multiple(() => + { + Assert.That(rows.WhereOpen(boundary).Select(x => x.Date), + Is.EqualTo(dates.Where(d => !DayLockHelper.IsLocked(boundary, d)))); + Assert.That(rows.WhereOpen(null).Count(), Is.EqualTo(dates.Length), + "no boundary, nothing is locked"); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index fb4b63ad..c293dc16 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -15,20 +15,46 @@ using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.UpdateCreate; using TimePlanning.Pn.Services.TimePlanningLocalizationService; using TimePlanning.Pn.Services.TimePlanningPlanningService; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using SdkLanguage = Microting.eForm.Infrastructure.Data.Entities.Language; +using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site; +using SdkSiteWorker = Microting.eForm.Infrastructure.Data.Entities.SiteWorker; +using SdkWorker = Microting.eForm.Infrastructure.Data.Entities.Worker; namespace TimePlanning.Pn.Test; [TestFixture] public class ReconcileServiceTests : TestBaseSetup { + /// The user BuildAdminIndexServiceAsync creates; the current-user paths find "my site" by it. + private const string AdminEmail = "admin@planning-index.test"; + + /// + /// Stored values on a locked day that UpdatePlanRegistrationsInPeriod + /// would overwrite if the day were open (SeedAssignedSiteAsync plans 0 + /// hours on every weekday, and no earlier row carries a flex balance in): + /// - PlanHours: the plan recompute (which would write 0) is skipped for + /// locked days outright, so this pins that skip. + /// - SumFlexEnd: the flex chain still runs on the tracked entity and + /// writes 0 + 0 - 7.5 = -7.5 in memory, so this pins the revert before + /// the projection. + /// + private const double StoredPlanHours = 7.5; + private const double StoredSumFlexEnd = 3.5; + private ITimePlanningPlanningService _service; private IUserService _userService; private ITimePlanningLocalizationService _localizationService; private IEFormCoreService _coreService; private ITimePlanningDbContextHelper _dbContextHelper; private IPluginDbOptions _options; + /// The logger BuildAdminIndexServiceAsync's service writes to; see AssertNoErrorLogged. + private ILogger _indexLogger; [SetUp] public async Task SetUpTest() @@ -103,8 +129,9 @@ private async Task SeedPlain(int siteId, DateTime date) /// call — Index() fans out per-site work concurrently, and production's /// helper also returns a new context per call. /// - /// Not used by this task's own tests, but copied verbatim so Tasks 5 and 6 - /// can append Index() tests to this fixture without duplicating it. + /// Also wires _userService to that user, which is what the current-user + /// paths (IndexByCurrentUserName, UpdateByCurrentUserNam, the personal + /// UpdateWorkingHour) resolve "me" from. /// private async Task BuildAdminIndexServiceAsync(BaseDbContext baseDbContext) { @@ -114,8 +141,8 @@ private async Task BuildAdminIndexServiceAsync(Bas var user = new EformUser { - UserName = "admin@planning-index.test", - Email = "admin@planning-index.test", + UserName = AdminEmail, + Email = AdminEmail, FirstName = "Admin", LastName = "PlanningIndex" }; @@ -129,8 +156,9 @@ private async Task BuildAdminIndexServiceAsync(Bas _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); + _indexLogger = Substitute.For>(); return new TimePlanningPlanningService( - Substitute.For>(), + _indexLogger, _options, TimePlanningPnDbContext, _dbContextHelper, @@ -140,6 +168,185 @@ private async Task BuildAdminIndexServiceAsync(Bas _coreService); } + /// + /// UpdatePlanRegistrationsInPeriod's catch-all logs and SWALLOWS whatever + /// its try throws, so a lock rejection there (a missing guard on an Update + /// inside the try) would not fail Index. It does log at Error level, which + /// this catches. Inspects ReceivedCalls, because LogError is an extension + /// method over the generic Log<TState> and cannot be matched directly. + /// + private void AssertNoErrorLogged() + { + var errors = _indexLogger.ReceivedCalls() + .Count(c => c.GetMethodInfo().Name == nameof(ILogger.Log) && c.GetArguments()[0] is LogLevel.Error); + Assert.That(errors, Is.Zero, "nothing on the Index path may be rejected and swallowed"); + } + + /// + /// The dashboard grid reads days BY POSITION, so the list must hold + /// exactly one entry per date of the window, in order (ruling F17). + /// + private static void AssertOneDayPerDate( + IReadOnlyList days, TimePlanningPlanningRequestModel window) + { + var from = window.DateFrom!.Value.Date; + var count = (window.DateTo!.Value.Date - from).Days + 1; + Assert.That(days, Has.Count.EqualTo(count), "one entry per date, locked gaps included"); + for (var i = 0; i < count; i++) + { + Assert.That(days[i].Date, Is.EqualTo(from.AddDays(i)), $"column {i} must be {from.AddDays(i):yyyy-MM-dd}"); + } + } + + /// + /// A registration device, so the kiosk UpdateWorkingHour overload accepts + /// its token. Returns the generated token for the call under test. + /// + private async Task SeedKioskDeviceAsync() + { + var token = Guid.NewGuid().ToString("N"); + await new RegistrationDevice + { + Token = token, + Name = "Kiosk Device", + OtpCode = "10001", + SoftwareVersion = "1.0.0", + Manufacturer = "Test", + Model = "Test", + OsVersion = "1.0", + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + return token; + } + + /// A plain day, reconciled, so it becomes the boundary. + private async Task SeedReconciledBoundaryAsync(int siteUid, DateTime date) + { + var row = await SeedPlain(siteUid, date); + var reconciled = await _service.Reconcile(row.Id); + Assert.That(reconciled.Success, Is.True, reconciled.Message); + return row; + } + + /// + /// Everything Index() needs to actually process a site: the SDK Site the + /// row takes its name from and the plugin AssignedSite it iterates. Without + /// both, Index() skips the site and a lock test passes vacuously (ruling F7). + /// + private async Task SeedAssignedSiteAsync(int siteUid, bool useGoogleSheetAsDefault = false) + { + var sdkDbContext = (await _coreService.GetCore()).DbContextHelper.GetDbContext(); + var sdkSite = new SdkSite { Name = $"Reconcile site {siteUid}", MicrotingUid = siteUid }; + await sdkSite.Create(sdkDbContext); + + await new AssignedSiteEntity + { + SiteId = siteUid, + // The recompute has two branches, each with its own guarded Update + // inside the catch-all. Default to the weekday-plan branch (the + // entity itself defaults to the Google-sheet one), whose all-zero + // plan is what makes StoredPlanHours stale. + UseGoogleSheetAsDefault = useGoogleSheetAsDefault, + // The personal mobile write path refuses past days without this, + // before its lock guard is ever reached. + AllowEditOfRegistrations = true, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext!); + + return sdkSite; + } + + /// + /// Makes the current user's site: a worker with + /// the admin's e-mail, attached to it. That is how the current-user paths + /// resolve "my site". + /// + private async Task SeedCurrentUserWorkerAsync(SdkSite sdkSite) + { + var sdkDbContext = (await _coreService.GetCore()).DbContextHelper.GetDbContext(); + var worker = new SdkWorker + { + FirstName = "Admin", + LastName = "PlanningIndex", + Email = AdminEmail, + MicrotingUid = 1000 + sdkSite.MicrotingUid!.Value + }; + await worker.Create(sdkDbContext); + await new SdkSiteWorker + { + SiteId = sdkSite.Id, + WorkerId = worker.Id, + MicrotingUid = 2000 + sdkSite.MicrotingUid!.Value + }.Create(sdkDbContext); + } + + /// + /// A reconciled day (so the boundary) storing values a dashboard load + /// rewrites in memory. That makes the loaded, TRACKED entity dirty -- the + /// precondition for the flush trap in ruling F15. Without it, merely + /// skipping the Update calls would look sufficient. + /// + /// IsSaturday is stored WRONG for the date, so the unconditional weekday + /// assignment before the first Update (the one outside the try, whose + /// throw nothing swallows) always dirties the row, whatever day of the + /// week the test runs on. + /// + private async Task SeedReconciledDayWithStaleStoredValuesAsync(int siteUid, DateTime date) + { + var row = await SeedPlain(siteUid, date); + row.PlanHours = StoredPlanHours; + row.SumFlexEnd = StoredSumFlexEnd; + row.IsSaturday = date.DayOfWeek != DayOfWeek.Saturday; + await row.Update(TimePlanningPnDbContext!); + + var reconciled = await _service.Reconcile(row.Id); + Assert.That(reconciled.Success, Is.True, reconciled.Message); + return row; + } + + private TimePlanningWorkingHoursService BuildWorkingHoursService(BaseDbContext baseDbContext = null) => + new(Substitute.For>(), + TimePlanningPnDbContext!, + _userService, + _localizationService, + baseDbContext, + _options, + _coreService); + + /// + /// One row as the working-hours page posts it (the page posts every row it + /// shows). Tests assert MessageId because the recompute inside + /// UpdatePlanning never touches it, unlike PlanHours. + /// + private static TimePlanningWorkingHoursModel Posted(DateTime date) => new() + { + Date = date, + Message = 3, + PlanText = "", + PaidOutFlex = "0", + CommentOffice = "", + CommentOfficeAll = "" + }; + + private static TimePlanningPlanningPrDayModel EditOf(PlanRegistration row) => new() + { + Id = row.Id, + Date = row.Date, + CommentOffice = "" + }; + + /// + /// With the boundary seeded on -5, this window holds the locked day AND + /// open days after it, which gap-fill creates and the loop then saves. + /// + private static TimePlanningPlanningRequestModel LastTenDaysThroughToday() => new() + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }; + [Test] public async Task Reconcile_APastDay_SetsFlagAndTimestamp() { @@ -365,4 +572,405 @@ public async Task ReconcileThrough_ADayAlreadyReconciledOnExactlyTheLandingDay_I Assert.That(result.Model.SkippedNoRegistration, Is.Empty); }); } + + // --------------------------------------------------------------------- + // Layer 2: user-facing write paths answer with a message. Without the + // guards, each of these reaches the interceptor and comes back as a + // generic error (or, where the method has no try/catch, a raw exception). + // --------------------------------------------------------------------- + + [Test] + public async Task Update_ADayBelowTheBoundary_ReturnsDayIsLockedByReconciledDay() + { + // Plain rows below a boundary must exist before it: afterwards the + // interceptor refuses to create them. + var earlier = await SeedPlain(920, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(920, DateTime.Now.Date.AddDays(-3)); + + var result = await _service.Update(earlier.Id, EditOf(earlier)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay")); + } + + [Test] + public async Task Update_TheBoundaryDay_ReturnsDayIsReconciled() + { + var boundary = await SeedReconciledBoundaryAsync(921, DateTime.Now.Date.AddDays(-3)); + + var result = await _service.Update(boundary.Id, EditOf(boundary)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "the reconciled day itself says what it is, not that something else locks it"); + } + + [Test] + public async Task Update_AnOpenDayAboveTheBoundary_StillSucceeds() + { + await SeedAssignedSiteAsync(922); + await SeedReconciledBoundaryAsync(922, DateTime.Now.Date.AddDays(-3)); + var open = await SeedPlain(922, DateTime.Now.Date.AddDays(-1)); + + var result = await _service.Update(open.Id, EditOf(open)); + + Assert.That(result.Success, Is.True, result.Message); + } + + [Test] + public async Task UpdateByCurrentUserNam_TheBoundaryDay_ReturnsDayIsReconciled() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(923)); + var boundary = await SeedReconciledBoundaryAsync(923, DateTime.Now.Date.AddDays(-3)); + + var result = await svc.UpdateByCurrentUserNam(EditOf(boundary)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "spec 11.3: the mobile path answers with the same message as web"); + } + + [Test] + public async Task CreateUpdate_AcrossTheBoundary_SkipsLockedRowsAndSavesOpenOnes() + { + // UpdatePlanning dereferences the AssignedSite. + await SeedAssignedSiteAsync(924); + var boundary = await SeedReconciledBoundaryAsync(924, DateTime.Now.Date.AddDays(-3)); + var existingOpen = await SeedPlain(924, DateTime.Now.Date.AddDays(-1)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + var missingOpenDate = DateTime.Now.Date.AddDays(-2); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 924, + Plannings = new List + { + // First, like the page's carried-over row: a locked first row + // must not stop the next row from being created. + Posted(boundary.Date), + Posted(missingOpenDate), + Posted(existingOpen.Date) + } + }); + + Assert.That(result.Success, Is.True, result.Message); + var lockedAfter = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + var created = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .SingleOrDefaultAsync(x => x.SdkSitId == 924 && x.Date == missingOpenDate); + var updated = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == existingOpen.Id); + Assert.Multiple(() => + { + Assert.That(lockedAfter.Version, Is.EqualTo(before.Version), + "the locked row is skipped, not re-saved"); + Assert.That(lockedAfter.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedAfter.MessageId, Is.Null, "the posted change to the locked row is ignored"); + Assert.That(created, Is.Not.Null, "an open day missing from the DB is still created"); + Assert.That(created?.MessageId, Is.EqualTo(3)); + Assert.That(updated.MessageId, Is.EqualTo(3), "an open day in the same request still saves"); + }); + } + + /// + /// Pins CreateUpdate's loop skip, which WhereOpen does not make redundant: + /// WhereOpen keeps locked ROWS out of the load, but a locked date with NO + /// row (a gap row the working-hours Index emits, and the page posts every + /// row) is not in the load either way. Without the skip, CreatePlanning + /// creates -5 inside the lock; its own catch swallows the interceptor's + /// throw, the Added entity stays tracked, the next save (-2's create, then + /// -1's update, which has no catch) flushes it again, and the request fails. + /// + [Test] + public async Task CreateUpdate_ALockedGapRowInTheMiddle_IsNotCreatedAndTheRestSaves() + { + await SeedAssignedSiteAsync(932); + var below = await SeedPlain(932, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(932, DateTime.Now.Date.AddDays(-3)); + var existingOpen = await SeedPlain(932, DateTime.Now.Date.AddDays(-1)); + var lockedGapDate = DateTime.Now.Date.AddDays(-5); + var openGapDate = DateTime.Now.Date.AddDays(-2); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 932, + Plannings = new List + { + Posted(below.Date), + // Not first, so without the skip CreatePlanning would create it. + Posted(lockedGapDate), + Posted(openGapDate), + Posted(existingOpen.Date) + } + }); + + Assert.That(result.Success, Is.True, result.Message); + var lockedGap = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .AnyAsync(x => x.SdkSitId == 932 && x.Date == lockedGapDate); + var created = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .SingleOrDefaultAsync(x => x.SdkSitId == 932 && x.Date == openGapDate); + var updated = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == existingOpen.Id); + Assert.Multiple(() => + { + Assert.That(lockedGap, Is.False, "a locked period does not grow new rows"); + Assert.That(created?.MessageId, Is.EqualTo(3), "the open gap row is created"); + Assert.That(updated.MessageId, Is.EqualTo(3), "the open stored row is updated"); + }); + } + + /// + /// Pins CreateUpdate's DB-side WhereOpen filter, which looks redundant next + /// to the loop's skip but is the only thing keeping the forward cascade + /// out of the lock. Posting only the locked -8 skips it in the loop; the + /// cascade then runs over every row after -8. Without the filter it would + /// re-chain -6 (stored balance 5, recomputed 0) and save it inside the + /// lock, the interceptor would reject that, and the request would fail. + /// + [Test] + public async Task CreateUpdate_PostingALockedDay_CascadesPastTheLockWithoutTouchingIt() + { + await SeedAssignedSiteAsync(931); + var below = await SeedPlain(931, DateTime.Now.Date.AddDays(-8)); + var insideLock = await SeedPlain(931, DateTime.Now.Date.AddDays(-6)); + insideLock.SumFlexStart = 5; + insideLock.SumFlexEnd = 5; + await insideLock.Update(TimePlanningPnDbContext!); + var boundary = await SeedReconciledBoundaryAsync(931, DateTime.Now.Date.AddDays(-3)); + // Stale balance, so the cascade's re-chain off the boundary visibly changes it. + var open = await SeedPlain(931, DateTime.Now.Date.AddDays(-1)); + open.SumFlexStart = 9; + open.SumFlexEnd = 9; + await open.Update(TimePlanningPnDbContext!); + async Task Stored(PlanRegistration row) => + await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking().FirstAsync(x => x.Id == row.Id); + var insideBefore = await Stored(insideLock); + var boundaryBefore = await Stored(boundary); + var openBefore = await Stored(open); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 931, + Plannings = new List { new() { Date = below.Date } } + }); + + Assert.That(result.Success, Is.True, result.Message); + var insideAfter = await Stored(insideLock); + var boundaryAfter = await Stored(boundary); + var openAfter = await Stored(open); + Assert.Multiple(() => + { + Assert.That(insideAfter.Version, Is.EqualTo(insideBefore.Version), "a locked row the cascade passes"); + Assert.That(boundaryAfter.Version, Is.EqualTo(boundaryBefore.Version), "the boundary"); + Assert.That(openAfter.Version, Is.GreaterThan(openBefore.Version), + "the open day after the lock is still recomputed by the cascade"); + Assert.That(openAfter.SumFlexStart, Is.EqualTo(boundaryAfter.SumFlexEnd), + "re-chained off the stored boundary balance"); + }); + } + + [Test] + public async Task WorkingHoursIndex_MarksReconciledLockedDaysAsIsLocked() + { + await SeedAssignedSiteAsync(930); + // Keep the MaxDaysEditable window out of the way, so only the + // reconciled lock can set IsLocked on these past days. + _options.Value.MaxDaysEditable = 365; + _userService.GetCurrentUserLanguage().Returns(new SdkLanguage { LanguageCode = "da" }); + await SeedPlain(930, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(930, DateTime.Now.Date.AddDays(-3)); + await SeedPlain(930, DateTime.Now.Date.AddDays(-1)); + + var result = await BuildWorkingHoursService().Index(new TimePlanningWorkingHoursRequestModel + { + SiteId = 930, + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date.AddDays(-1) + }); + + Assert.That(result.Success, Is.True, result.Message); + bool IsLockedOn(int daysAgo) => + result.Model.Single(x => x.Date == DateTime.Now.Date.AddDays(-daysAgo)).IsLocked; + Assert.Multiple(() => + { + Assert.That(IsLockedOn(8), Is.True, "a stored day below the boundary"); + Assert.That(IsLockedOn(3), Is.True, "the reconciled day itself"); + Assert.That(IsLockedOn(5), Is.True, "an empty day inside the lock (the gap-row path)"); + Assert.That(IsLockedOn(1), Is.False, "a stored day above the boundary stays editable"); + Assert.That(IsLockedOn(2), Is.False, "an empty day above the boundary stays editable"); + }); + } + + [Test] + public async Task UpdateWorkingHour_Kiosk_ADayBelowTheBoundary_IsRejectedAndCreatesNothing() + { + var token = await SeedKioskDeviceAsync(); + await SeedReconciledBoundaryAsync(925, DateTime.Now.Date.AddDays(-3)); + // No row on -4: without the guard the kiosk would CREATE one inside the + // lock, and this method has no try/catch to turn the rejection into a message. + var lockedDate = DateTime.Now.Date.AddDays(-4); + + var result = await BuildWorkingHoursService().UpdateWorkingHour(925, + new TimePlanningWorkingHoursUpdateModel { Date = lockedDate }, token); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay")); + Assert.That(await TimePlanningPnDbContext!.PlanRegistrations + .AnyAsync(x => x.SdkSitId == 925 && x.Date == lockedDate), Is.False); + } + + /// + /// Separates the message rule (the row's own Reconciled flag, as on web) + /// from the earlier "is it the boundary date" rule: -8 was reconciled + /// first, then -3, so -8 is reconciled but lies below the boundary. The + /// boundary-date rule answered DayIsLockedByReconciledDay here; web says + /// DayIsReconciled for the same day, and mobile must too (spec 11.3). + /// + [Test] + public async Task UpdateWorkingHour_Kiosk_AnOlderReconciledDay_ReturnsDayIsReconciled() + { + var token = await SeedKioskDeviceAsync(); + var older = await SeedReconciledBoundaryAsync(933, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(933, DateTime.Now.Date.AddDays(-3)); + + var result = await BuildWorkingHoursService().UpdateWorkingHour(933, + new TimePlanningWorkingHoursUpdateModel { Date = older.Date }, token); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled")); + } + + [Test] + public async Task UpdateWorkingHour_Personal_TheBoundaryDay_ReturnsDayIsReconciled() + { + await using var baseDbContext = GetBaseDbContext(); + // Seeds the admin user and points _userService at it ("me"). + await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(926)); + var boundary = await SeedReconciledBoundaryAsync(926, DateTime.Now.Date.AddDays(-3)); + + var result = await BuildWorkingHoursService(baseDbContext).UpdateWorkingHour( + new TimePlanningWorkingHoursUpdateModel { Date = boundary.Date }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "spec 11.3: the mobile path answers with the same message as web"); + } + + // --------------------------------------------------------------------- + // Layer 3: recalculation skips locked days, and a dashboard load over a + // closed period still succeeds (ruling F15: a dirty locked entity left + // behind would be flushed by the next open day's save and fail the load). + // Each asserts the positional one-entry-per-date contract (ruling F17) and + // that nothing was rejected and swallowed inside the recompute's catch. + // --------------------------------------------------------------------- + + /// + /// Run once per recompute branch: each branch has its own guarded Update + /// inside the catch-all, and AssertNoErrorLogged is what pins it. + /// + [TestCase(false)] + [TestCase(true)] + public async Task Index_OverALockedRange_LeavesLockedRowsByteIdentical(bool useGoogleSheetAsDefault) + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(927, useGoogleSheetAsDefault); + var row = await SeedReconciledDayWithStaleStoredValuesAsync(927, DateTime.Now.Date.AddDays(-5)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var window = LastTenDaysThroughToday(); + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + var days = result.Model.Single(x => x.SiteId == 927).PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedDay = days.Single(x => x.Date == row.Date); + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version), + "a no-op re-save still bumps Version — this catches silent rewrites"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedDay.Id, Is.EqualTo(row.Id)); + Assert.That(lockedDay.PlanHours, Is.EqualTo(StoredPlanHours), + "the grid shows the STORED, reconciled plan, not a recomputation"); + Assert.That(lockedDay.SumFlexEnd, Is.EqualTo(StoredSumFlexEnd), + "the grid shows the STORED, reconciled balance, not a recomputation"); + }); + } + + [Test] + public async Task Index_OverALockedRange_CreatesNoNewRows() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(928); + var boundary = DateTime.Now.Date.AddDays(-5); + await SeedReconciledDayWithStaleStoredValuesAsync(928, boundary); + var window = LastTenDaysThroughToday(); + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + var days = result.Model.Single(x => x.SiteId == 928).PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 928 && x.Date <= boundary); + var openRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 928 && x.Date > boundary); + Assert.Multiple(() => + { + Assert.That(lockedRows, Is.EqualTo(1), + "gap-fill must not materialise rows inside a frozen period"); + Assert.That(openRows, Is.EqualTo(5), + "gap-fill still fills the open days after the boundary (-4 .. today)"); + Assert.That(days.Where(x => x.Date < boundary).Select(x => x.Id), Is.All.EqualTo(0), + "the missing locked dates (-10 .. -6) are placeholders, with no row behind them"); + }); + } + + [Test] + public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(929)); + var row = await SeedReconciledDayWithStaleStoredValuesAsync(929, DateTime.Now.Date.AddDays(-5)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var window = LastTenDaysThroughToday(); + + var result = await svc.IndexByCurrentUserName(window, null, null, null, null); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + Assert.That(result.Model.SiteId, Is.EqualTo(929)); + var days = result.Model.PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedDay = days.Single(x => x.Date == row.Date); + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var lockedRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 929 && x.Date <= row.Date); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedDay.PlanHours, Is.EqualTo(StoredPlanHours), + "the mobile fetch shows the STORED, reconciled plan, not a recomputation"); + Assert.That(lockedDay.SumFlexEnd, Is.EqualTo(StoredSumFlexEnd)); + Assert.That(lockedRows, Is.EqualTo(1), + "the mobile path's gap-fill must not grow the frozen period either"); + Assert.That(days.Where(x => x.Date < row.Date).Select(x => x.Id), Is.All.EqualTo(0), + "the missing locked dates are placeholders on the mobile path too"); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index 33ed43f5..c1a5abdb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -66,6 +66,27 @@ public static class DayLockHelper public static bool IsLocked(DateTime? lockedThrough, DateTime date) => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + /// + /// The rows NOT locked by , as a filter the + /// database runs. Exactly equivalent to !IsLocked(lockedThrough, x.Date), + /// time of day included: date.Date <= lockedThrough.Date holds exactly + /// when date < lockedThrough.Date + 1 day. + /// + /// For bulk writers: a locked row that is never loaded is never tracked, + /// so no later SaveChanges on the context can flush a change into it. + /// + public static IQueryable WhereOpen( + this IQueryable query, DateTime? lockedThrough) + { + if (lockedThrough is not { } boundary) + { + return query; + } + + var firstOpenDay = boundary.Date.AddDays(1); + return query.Where(x => x.Date >= firstOpenDay); + } + /// /// Invariant I2: today and future days must stay open so time can still be /// registered. This is also what makes the forward flex cascades unable to 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 aefc50e8..c22d7bdf 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -420,9 +420,14 @@ public static async Task UpdatePlanRegistrationsInPer // ? new DateTime(DateTime.Now.Year, DateTime.Now.Month, settingsDayOfPayment, 0, 0, 0) // : new DateTime(DateTime.Now.Year, DateTime.Now.Month - 1, settingsDayOfPayment, 0, 0, 0); var dayOfPayment = toDay.AddMonths(-1); + // A dashboard load legitimately spans the boundary, so locked days are + // skipped rather than raising. Without this, the interceptor turns + // every visit to a closed month into a 500. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); foreach (var plan in planningsInPeriod) { var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id); + var dayIsLocked = DayLockHelper.IsLocked(lockedThrough, planRegistration.Date); var midnight = new DateTime(planRegistration.Date.Year, planRegistration.Date.Month, planRegistration.Date.Day, 0, 0, 0); // Mode at registration: the write-time marker when the row has one, @@ -468,7 +473,10 @@ public static async Task UpdatePlanRegistrationsInPer } planRegistration.IsSaturday = midnight.DayOfWeek == DayOfWeek.Saturday; planRegistration.IsSunday = midnight.DayOfWeek == DayOfWeek.Sunday; - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } if (!dbAssignedSite.Resigned) { @@ -476,7 +484,11 @@ public static async Task UpdatePlanRegistrationsInPer { if (dbAssignedSite.UseGoogleSheetAsDefault) { - if (planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) + // Locked days skip the plan recompute outright: it would be + // reverted anyway, and its "Plan hours changed" event (plus + // `tainted`, which repeats it for every later day) would + // fire falsely on every load of a closed period. + if (!dayIsLocked && planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) { if (!string.IsNullOrEmpty(planRegistration.PlanText)) { @@ -539,11 +551,16 @@ await dbContext.PlanRegistrations.AsNoTracking() FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } } else { - if (planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) + // Same as the Google-sheet branch: no plan recompute, and so + // no false "Plan hours changed" event, for a locked day. + if (!dayIsLocked && planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) { var dayOfWeek = planRegistration.Date.DayOfWeek; var originalPlanHours = planRegistration.PlanHours; @@ -807,6 +824,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Console.WriteLine($"The plannedHours are now: {planRegistration.PlanHours}"); + // Unguarded on purpose: this block only runs for open days. await planRegistration.Update(dbContext).ConfigureAwait(false); } @@ -831,7 +849,10 @@ await dbContext.PlanRegistrations.AsNoTracking() { FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } } } catch (Exception e) @@ -846,6 +867,18 @@ await dbContext.PlanRegistrations.AsNoTracking() } } + if (dayIsLocked) + { + // Frozen means frozen. The recomputation above mutated this TRACKED + // entity in memory. Reverting it (no query) means (a) the grid shows + // the STORED, reconciled values, not a recomputation, and (b) the next + // open day's SaveChanges cannot flush these changes into a locked row, + // which the interceptor would reject and fail the whole dashboard load. + var lockedEntry = dbContext.Entry(planRegistration); + lockedEntry.CurrentValues.SetValues(lockedEntry.OriginalValues); + lockedEntry.State = EntityState.Unchanged; + } + string? messageLabel = null; if (planRegistration.MessageId.HasValue && messagesById.TryGetValue(planRegistration.MessageId.Value, out var msg)) 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 2a199f53..f002e6d4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -368,7 +368,11 @@ await dbContext.AssignedSites } } - foreach (var missingDate in missingDates) + // Per site and from this site's own context: every worker has + // their own boundary, and sites run concurrently. + var lockedMissingDates = await LockedMissingDatesAsync( + innerDbContext, dbAssignedSite.SiteId, missingDates); + foreach (var missingDate in missingDates.Except(lockedMissingDates)) { var newPlanRegistration = new PlanRegistration { @@ -429,6 +433,7 @@ await innerDbContext.PlanRegistrations.AsNoTracking() midnightOfDateFrom, midnightOfDateTo, options); + AddLockedPlaceholderDays(siteModel, lockedMissingDates); return siteModel; }).ToList(); @@ -591,7 +596,9 @@ public async Task> IndexByCurrent } } - foreach (var missingDate in missingDates) + var lockedMissingDates = await LockedMissingDatesAsync( + dbContext, dbAssignedSite.SiteId, missingDates); + foreach (var missingDate in missingDates.Except(lockedMissingDates)) { var newPlanRegistration = new PlanRegistration { @@ -658,6 +665,7 @@ await dbContext.PlanRegistrations.AsNoTracking() midnightOfDateTo, options, messageLanguage); + AddLockedPlaceholderDays(siteModel, lockedMissingDates); siteModel.PlanningPrDayModels = model.IsSortDsc ? siteModel.PlanningPrDayModels.OrderByDescending(x => x.Date).ToList() @@ -700,6 +708,11 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel localizationService.GetString("PlanningNotFound")); } + if (await CheckDayLockAsync(planning) is { } dayLocked) + { + return dayLocked; + } + var assignedSite = await dbContext.AssignedSites .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstAsync(x => x.SiteId == planning.SdkSitId); @@ -1238,6 +1251,11 @@ public async Task UpdateByCurrentUserNam( localizationService.GetString("PlanningNotFound")); } + if (await CheckDayLockAsync(planning) is { } dayLocked) + { + return dayLocked; + } + // 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 @@ -2284,6 +2302,70 @@ private void CompareBoolField(List changes, string fieldName, bool? .FirstOrDefaultAsync(x => x.Id == id); } + /// + /// The day-lock guard for the web and mobile edit paths: a failure to + /// return when the day is locked, else null. Without it the interceptor + /// still refuses the write, but as a generic error instead of a message + /// saying what the day is. + /// + private async Task CheckDayLockAsync(PlanRegistration planning) + { + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + return DayLockHelper.IsLocked(lockedThrough, planning.Date) + ? new OperationResult(false, localizationService.GetString( + planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")) + : null; + } + + /// + /// The missing dates gap-fill must NOT create, because they fall inside the + /// site's lock: frozen means frozen, so a locked period does not grow new + /// rows (AddLockedPlaceholderDays stands in for them instead). The boundary + /// costs a query, so it is only resolved when there is anything to fill. + /// + private static async Task> LockedMissingDatesAsync( + TimePlanningPnDbContext ctx, int siteId, List missingDates) + { + if (missingDates.Count == 0) + { + return missingDates; + } + + var lockedThrough = await DayLockHelper.LockedThroughAsync(ctx, siteId); + return missingDates.Where(x => DayLockHelper.IsLocked(lockedThrough, x)).ToList(); + } + + /// + /// Gives each missing day inside the lock (which gap-fill may not create) + /// a NON-PERSISTED stand-in, then restores date order. The dashboard grid + /// reads days BY POSITION (column i = planningPrDayModels[i]), so a + /// missing entry would shift every later day one column left, under the + /// wrong header, and a click would open a different day. Id 0 marks a day + /// with no row; the values mirror what the projection yields for an empty + /// row. Nothing is written, so frozen still means frozen. + /// + private static void AddLockedPlaceholderDays( + TimePlanningPlanningModel siteModel, List lockedMissingDates) + { + if (lockedMissingDates.Count == 0) + { + return; + } + + siteModel.PlanningPrDayModels.AddRange(lockedMissingDates.Select(date => + new TimePlanningPlanningPrDayModel + { + Id = 0, + Date = date, + SiteId = siteModel.SiteId, + SiteName = siteModel.SiteName, + WeekDay = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek, + // |NettoHours - PlanHours| <= 0 on an empty row. + PlanHoursMatched = true + })); + siteModel.PlanningPrDayModels = siteModel.PlanningPrDayModels.OrderBy(x => x.Date).ToList(); + } + /// /// Sets Reconciled and ReconciledAt together and saves. I1: flag and /// timestamp always change together. DateTime.Now, not UtcNow: the diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index 94d4c63d..11ad6f9a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -101,6 +101,9 @@ public async Task>> Inde // Stage 3 tick-exact parity: per-row mode-at-registration from the // AssignedSiteVersions audit trail (one query; in-memory lookups). var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite); + // Reconciled days ride the existing IsLocked flag, which the page + // already renders read-only. One lookup; every row is checked in memory. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); var timePlanningRequest = dbContext.PlanRegistrations .AsNoTracking() @@ -177,7 +180,8 @@ public async Task>> Inde CommentWorker = x.WorkerComment.Replace("\r", "
"), CommentOffice = x.CommentOffice.Replace("\r", "
"), // CommentOfficeAll = x.CommentOfficeAll, - IsLocked = (x.Date < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || x.Date == midnight), + IsLocked = (x.Date < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || x.Date == midnight) + || DayLockHelper.IsLocked(lockedThrough, x.Date), IsWeekend = x.Date.DayOfWeek == DayOfWeek.Saturday || x.Date.DayOfWeek == DayOfWeek.Sunday, NettoHoursOverride = x.NettoHoursOverride, NettoHoursOverrideActive = x.NettoHoursOverrideActive, @@ -393,7 +397,8 @@ public async Task>> Inde Date = model.DateFrom.AddDays(i), WeekDay = (int)model.DateFrom.AddDays(i).DayOfWeek, IsLocked = model.DateFrom.AddDays(i) < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || - model.DateFrom.AddDays(i) == midnight, + model.DateFrom.AddDays(i) == midnight || + DayLockHelper.IsLocked(lockedThrough, model.DateFrom.AddDays(i)), IsWeekend = model.DateFrom.AddDays(i).DayOfWeek == DayOfWeek.Saturday || model.DateFrom.AddDays(i).DayOfWeek == DayOfWeek.Sunday //WorkerId = model.WorkerId, @@ -455,9 +460,20 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr { try { + // Locked rows arrive here because the page posts every row it shows. + // They are displayed read-only (Index sets IsLocked), so they are + // skipped, not rejected: rejecting would make any range touching a + // reconciled day unsaveable. The interceptor still guarantees that a + // crafted POST cannot write one. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); + // Locked rows are never even loaded, so nothing below can mutate + // one and have a later save flush it (ruling F15). The forward + // cascade walks this same list, so it skips them too. Not redundant + // with the loop's skip: only this keeps the cascade out of the lock. var planRegistrations = await dbContext.PlanRegistrations .Where(x => x.SdkSitId == model.SiteId) .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .WhereOpen(lockedThrough) .ToListAsync(); // Site-level one-minute flag drives the forward-cascade recompute below // so the double AND *InSeconds SumFlex columns are written consistently. @@ -470,18 +486,24 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr foreach (var planning in model.Plannings) { planning.Date = new DateTime(planning.Date.Year, planning.Date.Month, planning.Date.Day, 0, 0, 0); + if (DayLockHelper.IsLocked(lockedThrough, planning.Date)) + { + // Neither updated nor created. `first` is still cleared: the + // carried-over first row may be the locked one, and the next + // row must then be creatable as usual. + first = false; + continue; + } + var planRegistration = planRegistrations.FirstOrDefault(x => x.Date == planning.Date); if (planRegistration != null) { await UpdatePlanning( first, planRegistration, planning, model.SiteId, cascadeTimeline); } - else + else if (!first) { - if (!first) - { - await CreatePlanning(first, planning, model.SiteId, model.SiteId, planning.CommentWorker); - } + await CreatePlanning(first, planning, model.SiteId, model.SiteId, planning.CommentWorker); } first = false; @@ -1336,6 +1358,36 @@ private static void ApplyPunchClockFlexChainDecimal( FlexChain.ClearSumFlexSeconds(planRegistration); } + /// + /// The day-lock guard for both UpdateWorkingHour overloads: a failure to + /// return when the day is locked, else null. Neither overload has a + /// try/catch, so this answers with a message instead of letting the + /// interceptor throw. The message follows the planning service's rule, the + /// row's own Reconciled flag, so mobile says what web says for the same day + /// (spec §11.3). The row may not be loaded yet (or may not exist), so the + /// flag costs one cheap query, and only when the day is locked. + /// + private async Task CheckDayLockAsync(int? sdkSitId, DateTime date) + { + if (sdkSitId is not { } siteId) + { + return null; + } + + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, siteId); + if (!DayLockHelper.IsLocked(lockedThrough, date)) + { + return null; + } + + var day = date.Date; + var reconciled = await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .AnyAsync(x => x.SdkSitId == siteId && x.Date == day && x.Reconciled); + return new OperationResult(false, localizationService.GetString( + reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + } + public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model) { Console.WriteLine($"[DEBUG-GRPC-UPDATE] === UpdateWorkingHour (PERSONAL mode, 1-param) entered ==="); @@ -1397,6 +1449,11 @@ public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpd localizationService.GetString("EditingNotAllowedForWorker")); } + if (await CheckDayLockAsync(sdkSite.MicrotingUid, model.Date) is { } dayLocked) + { + return dayLocked; + } + var todayAtMidnight = model.Date; Console.WriteLine($"[DEBUG-GRPC-UPDATE] Querying PlanRegistrations: Date={todayAtMidnight:yyyy-MM-dd}, SdkSitId={sdkSite.MicrotingUid}"); @@ -2024,6 +2081,14 @@ public async Task UpdateWorkingHour(int? sdkSiteId, TimePlannin } Console.WriteLine($"[DEBUG-GRPC-UPDATE] KIOSK: registrationDevice found, Id={registrationDevice.Id}"); + // Before both branches below (each repeats its own assigned-site lookup), + // and after the token check so an unknown device learns nothing about + // the lock. + if (await CheckDayLockAsync(sdkSiteId, model.Date) is { } dayLocked) + { + return dayLocked; + } + registrationDevice.OsVersion = model.OsVersion; registrationDevice.Model = model.Model; registrationDevice.Manufacturer = model.Manufacturer; From 658e824c9fe79183c06ac10b389121b21b9eaddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 15:13:18 +0200 Subject: [PATCH 11/18] docs(lock): record implementation rulings and add the UI and write-path tasks The spec gains section 13, which lists what changed during execution: the payroll-flag exemption, the two-slot check, reverting locked days before any save, placeholder days for the positional grid, and the working-hours skip. Section 6.3 is corrected, and the section 8.3 grid gotcha now uses disableRowClickSelection. The plan gains Task 5B (remaining plugin write paths) and the reviewed UI tasks for section 8.1-8.4 (8A-14A), which settles the handoff's open decision. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-12-reconciled-day-lock.md | 3476 +++++++++++++++++ .../2026-09-13-HANDOFF-reconciled-day-lock.md | 6 +- .../2026-09-12-reconciled-day-lock-design.md | 52 +- 3 files changed, 3528 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md index c0fd0446..56625d34 100644 --- a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md @@ -2528,3 +2528,3479 @@ Re-running a failed job **overwrites** its conclusion, so a green run can hide a **Type consistency.** `lockedThrough` is `DateTime?` in C# and `string | null` in TS (JSON). `LockedThroughAsync` / `LockedThroughForSitesAsync` / `IsLocked` / `CanReconcile` are used with those exact names in Tasks 2, 4, 5, 6. `reconciled` / `reconciledAt` match the C# `Reconciled` / `ReconciledAt` under default camelCase JSON. CSS classes `locked-background` / `reconciled-background` match between Task 9 (emitted) and Tasks 10, 14 (styled, asserted). **Known risk carried forward:** Task 7 duplicates ~90 lines into the service repo. A divergence is a lock with a hole. Both copies carry a header comment naming the twin; there is no shared package to put it in without modifying the base. + +--- + +# Addendum A (2026-09-15): tasks added during execution + +These tasks were written and reviewed after the pre-flight scan and the write-path audits found gaps in the plan. Where they conflict with an earlier task, they supersede it; each says what it supersedes. + +## Task 5B: Guard the remaining plugin write paths (controller-authored; not in the original plan) + +**Why this task exists.** A write-path audit (`write-path-audit.md` in this directory, read it first) found PlanRegistration writes outside Task 5's scope that reach locked (past) days. With the interceptor live, each one either crashes something or leaves partial state. Rulings F10, F11 and F13 in `progress.md` settle the treatment. The rule is: **bulk or background re-syncs SKIP locked rows (frozen means frozen); a user acting on specific days gets a MESSAGE.** + +**Files (verify line numbers; they are from the audit):** +- `TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs` (`Run`) +- `TimePlanning.Pn/EformTimePlanningPlugin.cs` (`RepairCorruptedPauseIds` ~917 and its call in ConfigureServices ~208) +- `TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs` (`PullEverythingFromGoogleSheet` ~161-473) +- `TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` (`Import` ~3927-4126) +- `TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs` (`UpdateCreate` ~146-228, plus its follow-up loop ~168-211) +- `TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs` (`ApproveAsync` ~223-291) +- `TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs` (`AcceptAsync` ~584-921) +- Tests: extend the existing fixtures `CorruptedPauseIdRepairTests.cs`, `TimePlanningFlexServiceRemovedRowTests.cs` (or a sibling in the same shard), `AbsenceRequestServiceTests.cs`, `ContentHandoverServiceTests.cs` and `WorkingHoursImportRemovedRowTests.cs`; mirror each one's existing seeding. Put new cases in the EXISTING classes where possible, so no shard edit is needed. If you must add a class, add it to a shard filter in BOTH workflow files. + +**Interfaces consumed:** `DayLockHelper.LockedThroughAsync / LockedThroughForSitesAsync / IsLocked`, `DayLockedException`, `ReconciledDayLockInterceptor.Instance`, resx key `DayIsLockedByReconciledDay`. + +### THE TRAP (it applies to every SKIP below) +`PnBase.Create/Update/Delete` each call SaveChanges, which flushes EVERY dirty tracked entity on the context. "Skipping" a locked row therefore means: **do not mutate a tracked locked entity at all** (decide before mutating), or revert it (`entry.CurrentValues.SetValues(entry.OriginalValues); entry.State = Unchanged`) before the next save on that context. Skipping only the `.Update()` call while leaving the mutated entity tracked makes the NEXT save throw. For each skip, state in your report which of the two you used and why it is safe. + +Resolve boundaries ONCE per call or run (for many sites, `LockedThroughForSitesAsync` once), never per row. + +### Steps + +1. **CorruptedPauseIdRepair (startup, CRITICAL, ruling F13: defense in depth).** + a. In `Run`, resolve boundaries for the sites in the scan set once. Exclude or skip every row with `IsLocked(boundary, row.Date)` before mutating it. + b. `RepairCorruptedPauseIds` builds its own context via `TimePlanningPnContextFactory` (unguarded). Build it with the interceptor attached instead: reuse `TimePlanningDbContextHelper(connectionString).GetDbContext()` if that fits, otherwise the same options it uses (MariaDbServerVersion 10.5.0, EnableRetryOnFailure, `ReconciledDayLockInterceptor.Instance`). **Never ServerVersion.AutoDetect.** + c. Around the repair call in ConfigureServices, catch ONLY `DayLockedException`: log it + `SentrySdk.CaptureException`, and continue startup. Other exceptions keep today's behaviour. Comment why: a skip bug must never take the whole host down. + Test (CorruptedPauseIdRepairTests): a worker with a reconciled boundary and a corrupted-pause row inside the repair window AT OR BELOW the boundary, plus one above it. `Run` completes without throwing, the locked row is byte-identical (Version/UpdatedAt via AsNoTracking), and the unlocked row IS repaired. + +2. **Payroll export**: nothing to do. Ruling F10 exempts payroll-flag-only writes in the interceptor. Add ONE test proving it end to end if the PayrollExport fixture allows it cheaply (export a period containing a reconciled day, then assert the flag is set and there is no error). Otherwise say so. + +3. **GoogleSheetHelper.PullEverythingFromGoogleSheet**: SKIP rows whose date is locked for that worker. Resolve boundaries once for the mapped sites. No test is expected (it needs the Sheets API); say so. + +4. **WorkingHoursService.Import**: SKIP locked rows (dates are >= yesterday, so only a reconciled yesterday can hit this). Test in WorkingHoursImportRemovedRowTests style if its fixture drives Import; otherwise say so. + +5. **FlexService.UpdateCreate**: + a. Before ANY write, if any entry in the posted batch is locked for its site, return `OperationResult(false, localizationService.GetString("DayIsLockedByReconciledDay"))` (a message: a user is editing specific days). + b. The follow-up loop over rows with `Date > Now.AddDays(-2)` touches YESTERDAY, which may be reconciled, and it is NOT a forward cascade from an edited day, so spec §6.2 does not cover it. SKIP locked rows there. + Tests: the batch reject returns the message and writes nothing (a row above the boundary in the same batch stays unchanged); the follow-up loop with a reconciled yesterday does not throw. + +6. **AbsenceRequestService.ApproveAsync**: BEFORE persisting `Status = Approved`, check every requested day against the worker's boundary. If any is locked, return the localized failure (DayIsLockedByReconciledDay) and persist nothing. Test: approving a request covering a locked day leaves the request NOT approved and no day flagged. + +7. **ContentHandoverService.AcceptAsync**: BEFORE any write, check BOTH the sender's and the receiver's row for that date against each worker's own boundary. If either is locked, return the localized failure and write nothing. Test: accepting a handover where the sender's day is locked leaves both rows and the request status unchanged. + +8. `PlanRegistrationHelper.UpdatePlanRegistration` (swallowing catch): no code change. CreateUpdate's Task 5 entry guard rejects any request containing a locked day, and cascades stay above the boundary (I2). Confirm by reading, and record it in the report. + +9. Build: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q`, 0 errors, no new warnings in touched files. + +Commit message (when told to): `fix(lock): keep startup repair, sheet pull, import, flex, absence and handover inside the lock` + +--- + +# Addendum B (2026-09-15): UI tasks for spec §8.1–8.4 + +Written after the user put §8.1–8.4 in scope, then reviewed twice against the real code (the second verdict: ready). Task *N*A/*N*B extends Task *N*, and the brief extraction for Task *N* includes them. + + +These tasks slot into `docs/superpowers/plans/2026-09-12-reconciled-day-lock.md` beside +the frontend tasks they extend. The plan's Global Constraints apply unchanged: dev mode +NONE, tests run in CI only, staging by file name, SCSS only in `eform-angular-frontend`, +and the copy rule (a string says what the day *is* and never mentions administrators). + +*Revision 2 applies the plan review: 0 critical, 3 important, 12 minor.* + +| Task | Follows | Spec | Repo | +|---|---|---|---| +| 8A | Task 8 | §7, correction | plugin | +| 9A | Task 9 | §8.1 glyphs, tooltips, legend; shared helpers | plugin | +| 10A | Task 10 | §8.1-8.4 styles | **eform-angular-frontend** (Task 10's branch and PR) | +| 11A | Task 11 | §8.2 inline confirm, read-only in place, provenance | plugin | +| 11B | 11A | §8.4 typed-word unlock, "free this day first" | plugin | +| 12A | Task 12 | §8.3 preview, header click, scope bar, per-site toast | plugin | +| 13A | Task 13 | strings for all of the above, help-wiring spec | plugin | +| 14A | Task 14 | shard `s` bootstrap, Task 14's spec rebuilt on the shared helpers | plugin | + +**Execution order.** 8 → 8A → 9 → 9A → 10 → 10A → 11 → 11A → 11B → 12 → 12A → 13 → 13A → 14 → 14A. +Where a lettered task supersedes a step of its parent, an implementer who has not run +the parent yet should skip that step and apply the lettered version. Every supersession +is listed at the top of the task. + +## Conflicts with plan Tasks 8-14 + +The plan review checked these against the code. #4 and #6 are corrected here. + +1. **Task 8:** the TS `ReconcileThroughResultModel` has the wrong shape, and the + service uses the type without importing it. Fixed in 8A. +2. **Task 10, dark mode:** the dark overrides key off + `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])`. This app + switches dark mode with `body.theme-dark`, so the tokens would follow the OS setting, + not the app setting. Fixed in 10A. +3. **Task 10, legibility:** in dark mode the lock ground `#262B29` sits under the state + classes' hard-coded dark text. That covers `.plan-text` and also `.comment`, which + inherits `#0F1316 !important` from `.X-background .plan-container`. Task 10 also dims + the lock glyph along with the cell. Fixed in 10A. +4. **§8.1 filled seal (corrected):** a filled `verified` cannot come from the Outlined + face, for two reasons. + - `index.html:15` loads Outlined with FILL fixed at 0. + - Under `body.theme-workspace`, `_workspace-mat-overrides.scss:27,36-44` forces + *every* `.mat-icon` onto Outlined with `!important`. + + `fontSet="material-symbols-rounded"` with the class `.filled` is therefore not + enough on its own. 9A/10A add a `tp-seal` class and a rule that out-specifies the + workspace override. +5. **Task 11 Step 4:** `btn-secondary` inside `mat-dialog-actions` fails the plugin + CI's "Button conventions" step. That step runs in the `build` job, which gates the + PR. Fixed in 11A and 11B. +6. **Task 11 Step 3 (rationale corrected):** the bound `[helpId]` and + `[attr.data-tp-help]` attributes do **not** fail `help-wiring.spec.ts`. They slip + past it: its regexes read only literal `helpId=` and `data-tp-help=` attributes. The + two banner ids would then sit outside the registry checks and outside the exhaustive + hint list, so a typo in either id would never be caught. The fix stays: two hints + with fixed ids, plus the hint-list update in 13A. +7. **Task 11 Step 3 / §8.5:** `tp-help-hint` renders only for admins + (`HelpVisibilityService`). Non-admin users can reconcile (§8.6), so they would see + no locked banner. The copy that matters goes in the footer as plain translated + text. +8. **Task 11 Step 5:** reconcile and unlock close with `this.data`. The table's + `afterClosed` treats that as a save and calls `updatePlanning` on a day that is now + locked, which is refused. Fixed with the `lockStateChanged` close contract (11A). +9. **Tasks 11-12:** every new literal template key fails `help-wiring.spec.ts`'s frozen + `TEMPLATE_TRANSLATE_KEYS`. Task 13 adds keys to `da` and `enUS` only. Fixed in 13A. +10. **Task 12:** `maxReconcileDate` uses `toISOString()`, which is UTC, so the max is + one day early between 00:00 and 02:00 Danish time. +11. **Task 12:** the toolbar commits directly, with no preview, and ignores the + per-site outcome. +12. **Task 12, gotcha 1:** the fix covers only the day cell. A click on the Name + column still clears the batch selection. `[disableRowClickSelection]` fixes both + at the source. +13. **Task 12, gotcha 2:** the re-emit happens only when `timePlannings` changes. The + grid also drops the selection when `[columns]` or `[headerTemplate]` change. +14. **Task 12, housekeeping:** its commit uses a directory `git add`. The container + spec needs a `ToastrService` provider once the container injects it. +15. **Task 13:** it registers the anchor `toolbar.reconcileThrough`, but Task 12's + markup never carries it. +16. **Task 14, bootstrap:** shard `s` has no `activate-plugin.spec.ts` or + `assert-true.spec.ts` bootstrap, which shards `q` and `r` have. The plugin never + loads, and every `s` spec fails. Fixed in 14A. +17. **Task 14, the spec itself:** + - It uses the current week. On a Monday or a Tuesday, `#cell3_1` is in the future + or is today, so the reconcile button is not there. + - It addresses rows by the positional `#cellN_M` ids. + - It clicks reconcile and unlock once each, which no longer matches the two-step + and typed-word flows. + + Fixed in 14A. +18. **Directory `git add`s:** Task 8 Step 3, Task 11 Step 6, Task 13 Step 6 and Task 14 + Step 3 stage directories. 8A, 11A, 13A and 14A replace each with an explicit list + of files. +19. **Spec tension, left as written:** §8.1 gives a locked cell the cursor + `not-allowed`, but §8.5 says the cell still opens a read-only dialog when clicked. + +## Spec edits for the controller + +Apply these to `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md`: + +1. **§8.3, gotcha 1.** Replace "The day cell must call `stopRowClick($event)`" with: + "Set `[disableRowClickSelection]="true"` on the grid. `_selectRow` then leaves the + selection alone for any click on the row (the day cell *and* the Name column), + while still emitting `rowClick`. Rows are selected by their checkbox only." +2. **§8.1 constraint list.** Add: "The filled `verified` seal uses + `fontSet="material-symbols-rounded"` with the class `filled tp-seal`. Outlined is + loaded with FILL fixed at 0, and theme-workspace forces every `.mat-icon` onto + Outlined, so the host styles carry a `tp-seal` rule that restores Rounded with + FILL 1." + +## Facts these tasks rely on + +Each fact was checked against the code on 2026-09-15. + +1. **mtx-grid 20.4.2 stamps the `` class through a *pure* pipe:** + `[class]="col | colClass: row: rowChangeRecord: ..."`. The pipe re-runs only when + the row object changes, so `getCellClass` cannot show a preview that is driven by + container state. The preview classes go on `.plan-container` in the cell template, + which is evaluated on every change-detection pass. +2. **mtx-grid rebuilds `rowSelection` in `ngOnChanges` on *any* input change** + (`new SelectionModel(this.multiSelectable, this.rowSelected)`). That includes + `[columns]` and `[headerTemplate]`, not only `[data]`, and it emits nothing. +3. **`_selectRow()` skips the selection when `disableRowClickSelection` is set, but + still emits `rowClick`** (`mtxGrid.mjs:1226-1238`). +4. **mtx-grid's `headerTemplate` input accepts a `{[field]: TemplateRef}` map.** + Columns missing from the map keep the default header. +5. **`MatDialogRef.componentInstance` is set to `null` in `_finishDialogClose()`.** + Read it at open time, not after close. +6. **`index.html:15` loads `Material Symbols Outlined` as a static instance with FILL + fixed at 0.** On top of that, under `body.theme-workspace`, + `_workspace-mat-overrides.scss:27,36-44` sets + `.mat-icon { font-family: 'Material Symbols Outlined' !important }` with selector + specificity (0,3,1), for every icon. `Material Symbols Rounded` is loaded with + FILL 0..1. A filled glyph therefore needs Rounded **and** a rule above (0,3,1): the + `tp-seal` rule in 10A, at (0,4,1). +7. **This app switches dark mode with `body.theme-dark`** (`full-layout.component.ts:94-100`). + It does not use `prefers-color-scheme` or `data-theme`. +8. **The "Button conventions" step (`check-button-conventions.js`) runs in the plugin + PR's `build` job**, which is not `continue-on-error`. In `mat-dialog-actions`, every + button must be `.btn-primary`, `.btn-cancel`, `.btn-delete` or `.btn-quiet`, and the + first one in source order must be `.btn-cancel`. The script reads the whole row and + ignores `*ngIf`. +9. **`help/help-wiring.spec.ts` builds its `MARKUP` from the raw container, table and + dialog templates, comments included** (:37). It then checks four things: + - **Frozen keys:** the set of literal `'key' | translate` keys must equal + `TEMPLATE_TRANSLATE_KEYS`. + - **Exhaustive hints:** its list of `tp-help-hint` ids is complete. + - **Registry:** every `helpId=` and `data-tp-help=` value found by literal regex + (:69, :78) must exist in the registry. + - **Help icons:** it counts ` LandedOnBySiteId; int Applied; List SkippedAlreadyFurtherForward; List SkippedNoRegistration; List AlreadyReconciledSiteIds`. +- Produces: `ReconcileThroughResultModel { landedOnBySiteId, applied, skippedAlreadyFurtherForward, skippedNoRegistration, alreadyReconciledSiteIds }`. + 12A consumes it. + +Task 8's model had `{landedOn, applied, skippedSiteIds}`. The server never sends those +names, so every read in 12A would be `undefined`. The model must mirror the C# model +field for field. The host's Newtonsoft contract resolver camelCases the names, and +dictionary keys arrive as strings. + +- [ ] **Step 1: Replace the model** + +`models/plannings/reconcile-through-result.model.ts`: + +```ts +/** + * Mirrors the C# ReconcileThroughResultModel (plan Task 4) field for field. + * + * Per-worker landing, because the boundary is a staircase: the mark falls on each + * worker's own latest registered day at or before the requested date, so one shared + * "landedOn" would name the wrong date for most of them. + */ +export class ReconcileThroughResultModel { + /** siteId -> the day the mark landed on. JSON object keys arrive as strings. */ + landedOnBySiteId: { [siteId: number]: string } = {}; + /** Workers whose boundary actually moved. Excludes no-ops. */ + applied: number; + /** Already reconciled at or past the target. Moving them back would be an unlock. */ + skippedAlreadyFurtherForward: number[] = []; + /** No registration at or before the target, so there was nothing to mark. */ + skippedNoRegistration: number[] = []; + /** Already marked on exactly the landing day; nothing changed. */ + alreadyReconciledSiteIds: number[] = []; +} +``` + +Task 8 already exports it from `models/plannings/index.ts`. Check that the export line +is there. + +- [ ] **Step 2: Import the model where the service uses it** + +Task 8's `reconcileThrough()` names the type, but Task 8 never adds the import. Extend +the existing `from '../models'` import in `time-planning-pn-plannings.service.ts`: + +```ts +import { + PlanningPrDayModel, + ReconcileThroughResultModel, + TimeFlexesModel, + TimeFlexesUpdateModel, + TimePlanningModel, + TimePlanningsRequestModel, + TimePlanningsUpdateModel, + TimePlanningUpdateModel, + PlanRegistrationVersionHistoryModel, +} from '../models'; +``` + +- [ ] **Step 3: Task 8's own commit, staged by name** (replaces Task 8 Step 3) + +Run this only if Task 8's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "feat(lock): add the lock fields and reconcile calls to the client" +``` + +- [ ] **Step 4: Commit 8A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "fix(lock): mirror the server's per-site reconcile-through result" +``` + +--- + +## Task 9A: Glyphs, tooltips, the legend, and the shared helpers (§8.1) + +**Follows:** Task 9. It needs `isDayLocked` and `isDayReconciled` from there. +**Supersedes:** nothing. It adds to Task 9. + +**Files:** +- Create: `eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts` (final, including the bulk preview builder that 12A uses) +- Create: `eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts` (final) +- Modify: `.../components/plannings/time-plannings-table/time-plannings-table.component.ts` +- Modify: `.../components/plannings/time-plannings-table/time-plannings-table.component.html` (day-cell template :237-434; below `` :30) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts` + +**Interfaces:** +- Consumes: + - `PlanningPrDayModel.reconciled` and `.reconciledAt`, `TimePlanningModel.lockedThrough` (Task 8) + - `isDayLocked` and `isDayReconciled` (Task 9) + - classes from 10A: `tp-day-glyph*`, `tp-seal`, `tp-lock-legend*` + - keys from 13A: `lockedTooltip`, `reconciledLegend`, `reconciledProvenance`, `Reconciled` +- Produces: + - `dayKey`, `formatReconciledProvenance`, `buildReconcilePreview`, `ReconcilePreview` and + `ReconcileRowOutcome`. 11A, 11B and 12A use them, and none of them rewrites this file. + - Table: `hasLockedDayInView` and `reconciledTooltip(row, field)`. + - Playwright: `s/reconcile-helpers.ts`, used by every later spec. + +**The two glyphs.** +- **`lock`:** an outline lock at the bottom-left marks a cascade-locked day. It uses + `fontSet="material-symbols-outlined" class="neutral-icon"`, like every other + day-cell icon. +- **`verified`:** a filled seal at the top-right marks the reconciled boundary day. It + uses `fontSet="material-symbols-rounded" class="neutral-icon filled tp-seal"` for + three reasons (fact 6): + - Outlined is loaded with FILL fixed at 0, so the glyph must come from Rounded. + - Theme-workspace pushes every `.mat-icon` back onto Outlined, and the `tp-seal` + rule in 10A overrides that. + - `neutral-icon` keeps it the same size and offset as its neighbours. + +**Positioning without fighting `.neutral-icon`.** That class forces +`position: relative !important; top: 5px !important`, so neither glyph is absolutely +positioned. +- **The lock** is the **last child of `.plan-content`**. 10A makes `.plan-content` a + flex column in a locked cell and gives the glyph `margin-top: auto`, which puts it + bottom-left. +- **The seal** is the **last child of `.plan-icons`**, which puts it at the top-right. + It never overlaps the message icons, because it takes a place in their row. + +- [ ] **Step 1: The shared helper, written once** + +`components/plannings/day-lock.util.ts`. The preview builder lives here from the start, +so 12A only consumes it: + +```ts +import {DatePipe} from '@angular/common'; +import {TranslateService} from '@ngx-translate/core'; +import {format} from 'date-fns'; +import {TimePlanningModel} from '../../models'; + +/** + * Calendar-day key 'yyyy-MM-dd', for comparing days with no timezone involved. + * + * Server dates (PlanningPrDayModel.date, TimePlanningModel.lockedThrough) arrive as + * server-local midnight with no offset, e.g. '2026-09-07T00:00:00', so the first ten + * characters ARE the calendar day. Turning them into a Date and comparing instants is + * how a boundary ends up a day off for part of every evening. A Date is formatted in + * local time. + */ +export function dayKey(value: string | Date | null | undefined): string | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : format(value, 'yyyy-MM-dd'); + } + return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null; +} + +/** + * "Afstemt 14.09.2026 kl. 10:32" (spec §8.1, §8.2). There is no "by whom": + * ReconciledBy is a declared non-goal. + * + * ReconciledAt is written as DateTime.Now (Task 4) and read back from a datetime(6) + * column, so EF materialises it as DateTimeKind.Unspecified. Newtonsoft + * (RoundtripKind) serialises it with NO offset, and the browser reads it as local + * wall-clock time, which is the server's clock on a Danish deployment. Do NOT pass + * 'UTC' here, the way formatStamp does for the shift stamps. + */ +export function formatReconciledProvenance( + reconciledAt: string | null | undefined, + datePipe: DatePipe, + translate: TranslateService, +): string { + if (!reconciledAt) { + // I1 says a reconciled day always has a timestamp; this covers a malformed row. + return translate.instant('Reconciled'); + } + return translate.instant('reconciledProvenance', { + date: datePipe.transform(reconciledAt, 'dd.MM.yyyy'), + time: datePipe.transform(reconciledAt, 'HH:mm'), + }); +} + +/** 'lock' = the boundary moves to a day on screen. 'skip' = already at or past the target. */ +export type ReconcileRowOutcome = 'lock' | 'skip'; + +export interface ReconcilePreview { + /** 'yyyy-MM-dd'. Sent to reconcile-through as it is. */ + target: string; + /** + * Workers sent to reconcile-through: every row in scope that the preview drew, + * as 'lock' or as 'skip', each once. Skipped rows are sent too, so that the server + * reports them. A row the preview could not draw is never sent (§8.3: the region + * is previewed before it is committed). + */ + siteIds: number[]; + /** Per worker: the day the mark lands on. */ + landingBySiteId: Record; + /** Per worker: the boundary they already have, so already-locked cells are not re-highlighted. */ + existingBySiteId: Record; + outcomeBySiteId: Record; + /** Workers whose boundary will move. */ + willReconcileCount: number; + skipCount: number; +} + +/** + * The region a reconcile-through WILL lock, per worker (spec §8.3). It mirrors the + * server rules in Task 4: + * - A worker already reconciled at or past the target is skipped, never moved back. + * Moving back would be an unlock, which is a separate, heavier action (§8.4). + * - Otherwise the mark lands on the latest day at or before the target that has a + * registration. + * + * Only days on screen are known here. A row with no registered day on screen + * between its boundary and the target has nothing to draw, so it is left out of the + * commit. The date field is limited to days on screen (12A) and the index creates a + * registration for every visible day (fact 11), so this only happens when a visible + * day failed to materialise. + */ +export function buildReconcilePreview( + rows: TimePlanningModel[], + scopeSiteIds: number[], + target: string, +): ReconcilePreview { + const inScope = new Set(scopeSiteIds); + const preview: ReconcilePreview = { + target, + siteIds: [], + landingBySiteId: {}, + existingBySiteId: {}, + outcomeBySiteId: {}, + willReconcileCount: 0, + skipCount: 0, + }; + + for (const row of rows) { + if (!inScope.has(row.siteId) || row.siteId in preview.outcomeBySiteId) { + continue; + } + const existing = dayKey(row.lockedThrough); + preview.existingBySiteId[row.siteId] = existing; + + if (existing !== null && existing >= target) { + preview.outcomeBySiteId[row.siteId] = 'skip'; + preview.siteIds.push(row.siteId); + preview.skipCount++; + continue; + } + + const landing = (row.planningPrDayModels ?? []) + .filter(day => !!day?.id) + .map(day => dayKey(day.date)) + .filter((key): key is string => key !== null && key <= target) + .sort() + .pop(); + if (!landing || (existing !== null && landing <= existing)) { + continue; + } + + preview.landingBySiteId[row.siteId] = landing; + preview.outcomeBySiteId[row.siteId] = 'lock'; + preview.siteIds.push(row.siteId); + preview.willReconcileCount++; + } + return preview; +} +``` + +- [ ] **Step 2: Unit tests, written once** + +`components/plannings/day-lock.util.spec.ts`. It runs in CI's `angular-unit-test` job, +which is `continue-on-error` and so does not gate the PR. Keep it green anyway. + +```ts +import {DatePipe} from '@angular/common'; +import {buildReconcilePreview, dayKey, formatReconciledProvenance} from './day-lock.util'; + +describe('day-lock util', () => { + describe('dayKey', () => { + it('takes the calendar day from a server date without parsing it', () => { + expect(dayKey('2026-09-07T00:00:00')).toBe('2026-09-07'); + }); + + it('formats a Date in local time', () => { + expect(dayKey(new Date(2026, 8, 7, 23, 30))).toBe('2026-09-07'); + }); + + it('returns null for missing or malformed input', () => { + expect(dayKey(null)).toBeNull(); + expect(dayKey(undefined)).toBeNull(); + expect(dayKey('not a date')).toBeNull(); + }); + }); + + describe('formatReconciledProvenance', () => { + const translate = {instant: jest.fn((key: string, params?: object) => key)} as any; + + it('passes the wall-clock date and time through unshifted', () => { + formatReconciledProvenance('2026-09-14T10:32:11', new DatePipe('en-US'), translate); + expect(translate.instant) + .toHaveBeenLastCalledWith('reconciledProvenance', {date: '14.09.2026', time: '10:32'}); + }); + + it('falls back to the bare state name when there is no timestamp', () => { + formatReconciledProvenance(null, new DatePipe('en-US'), translate); + expect(translate.instant).toHaveBeenLastCalledWith('Reconciled'); + }); + }); + + describe('buildReconcilePreview', () => { + const day = (date: string, id = 1) => ({id, date: `${date}T00:00:00`}) as any; + const row = (siteId: number, lockedThrough: string | null, days: any[]) => ({ + siteId, + lockedThrough: lockedThrough ? `${lockedThrough}T00:00:00` : null, + planningPrDayModels: days, + }) as any; + const week = ['2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10'].map(d => day(d)); + + it('lands on the latest registered day at or before the target', () => { + const preview = buildReconcilePreview([row(1, null, week)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('lock'); + expect(preview.siteIds).toEqual([1]); + expect(preview.willReconcileCount).toBe(1); + }); + + it('skips a worker at or past the target, never moves the line back, and still sends it', () => { + const preview = buildReconcilePreview( + [row(1, '2026-09-10', week), row(2, '2026-09-09', week)], [1, 2], '2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('skip'); + expect(preview.outcomeBySiteId[2]).toBe('skip'); + expect(preview.siteIds).toEqual([1, 2]); + expect(preview.skipCount).toBe(2); + expect(preview.willReconcileCount).toBe(0); + }); + + it('ignores days without a registration id', () => { + const days = [day('2026-09-07'), day('2026-09-08', 0), day('2026-09-09', 0)]; + const preview = buildReconcilePreview([row(1, null, days)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-07'); + }); + + it('never sends a row it could not draw', () => { + const unregistered = ['2026-09-07', '2026-09-08'].map(d => day(d, 0)); + const preview = buildReconcilePreview([row(1, null, unregistered)], [1], '2026-09-08'); + expect(preview.siteIds).toEqual([]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + expect(preview.willReconcileCount).toBe(0); + }); + + it('keeps the existing boundary so already-locked days are not highlighted again', () => { + const preview = buildReconcilePreview([row(1, '2026-09-07', week)], [1], '2026-09-09'); + expect(preview.existingBySiteId[1]).toBe('2026-09-07'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + }); + + it('previews only the workers in scope, and sends each once', () => { + const preview = buildReconcilePreview([row(1, null, week), row(2, null, week)], [2, 2], '2026-09-08'); + expect(preview.siteIds).toEqual([2]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + }); + }); +}); +``` + +- [ ] **Step 3: Table component, tooltip and legend switch** + +In `time-plannings-table.component.ts`, add the import. 12A extends this line: + +```ts +import {dayKey, formatReconciledProvenance} from '../day-lock.util'; +``` + +Add to the class, beside Task 9's `isDayLocked` and `isDayReconciled`: + +```ts + /** + * Legend switch (spec §8.1): shown whenever a locked day is on screen, because + * otherwise nobody learns what the hatch means. Recomputed when rows arrive, never + * on every change-detection pass. + */ + hasLockedDayInView = false; + + /** Seal tooltip: "Afstemt 14.09.2026 kl. 10:32". */ + reconciledTooltip(row: any, field: string): string { + return formatReconciledProvenance( + row?.planningPrDayModels?.[field]?.reconciledAt, this.datePipe, this.translateService); + } + + private computeHasLockedDayInView(): boolean { + return (this.timePlannings ?? []).some(row => { + const boundary = dayKey(row.lockedThrough); + return boundary !== null + && (row.planningPrDayModels ?? []).some(day => { + const key = dayKey(day?.date); + return key !== null && key <= boundary; + }); + }); + } +``` + +At the end of `ngOnChanges`, after the existing `waitingForFreshData` block (:81-85): + +```ts + if (changes.timePlannings) { + this.hasLockedDayInView = this.computeHasLockedDayInView(); + } +``` + +- [ ] **Step 4: The glyphs in the day cell** + +In `#dayColumnTemplate` in `time-plannings-table.component.html`: + +**(a)** Add the lock immediately before the `
` that closes `.plan-content`. That is +the line after the `(id: …)` block, :417-418: + +```html + + + lock + +``` + +**(b)** Add the seal immediately before the `` that closes `.plan-icons`, after +the `message === 13` icon, :431-432: + +```html + + + verified + +``` + +- [ ] **Step 5: The legend under the grid** + +In the same template, directly after `
` (:30) and before +``: + +```html + +
+ + + lock + + {{ 'lockedTooltip' | translate }} + + + + verified + + {{ 'reconciledLegend' | translate }} + +
+``` + +The legend reuses `lockedTooltip`, so the tooltip and the legend say the same words. +13A adds the keys. Until then they render as bare keys. + +- [ ] **Step 6: Shared Playwright helpers** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts`. +Playwright does not collect it as a test, because the name does not match `*.spec.ts`. +The helpers target the finished UI, including the 11A and 11B ids. They run only once +Task 14 adds shard `s` to the matrix, and by then every one of those ids exists. + +```ts +import { expect, Locator, Page, Response } from '@playwright/test'; + +/** + * Shared by every spec in shard s. Two rules are enforced here so no spec can forget + * them: + * + * 1. Rows are found by WORKER NAME, never by grid position. The `#cell{row}_{day}` + * ids are positional, and a row shift silently addresses another worker. That is + * exactly how e1m/dashboard-edit-multishift.spec.ts once failed. A position is + * read once per spec, to PICK a worker, and never again. + * 2. The grid is always opened on LAST week, so every visible day is in the past. + * Reconcile is refused for today and the future (I2), and the current week has + * no past day at all on a Monday. + * + * Every spec uses its own worker and unlocks what it locked, because the shard shares + * one database and runs its specs in file order (workers: 1). + */ + +const INDEX_PATH = '/api/time-planning-pn/plannings/index'; +export const RECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/reconcile$/; +export const UNRECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/unreconcile$/; +export const RECONCILE_THROUGH_PATH = /\/api\/time-planning-pn\/plannings\/reconcile-through$/; +/** Any write to a day: a save (PUT plannings/{id}) as well as reconcile and unlock. */ +export const PLANNING_PUT_PATH = /\/api\/time-planning-pn\/plannings\//; +/** Danish, like the rest of the suite: CI runs the UI in Danish. */ +export const UNLOCK_WORD = 'LÅS OP'; +export const LOCKED_TOOLTIP = 'Låst · ligger før en afstemt dag'; +export const PROVENANCE = /^Afstemt \d{2}\.\d{2}\.\d{4} kl\. \d{2}:\d{2}$/; + +export async function waitForSpinner(page: Page): Promise { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +/** The grid's index POST. With `dateFrom` it only accepts a load of that period. */ +export function waitForIndex(page: Page, dateFrom?: string): Promise { + return page.waitForResponse(r => + r.url().includes(INDEX_PATH) + && r.request().method() === 'POST' + && (dateFrom === undefined || `${r.request().postDataJSON()?.dateFrom ?? ''}`.startsWith(dateFrom))); +} + +/** Matches on the pathname: a bare includes('/reconcile') also matches /unreconcile. */ +export function waitForPut(page: Page, path: RegExp): Promise { + return page.waitForResponse(r => + r.request().method() === 'PUT' && path.test(new URL(r.url()).pathname)); +} + +/** Counts the PUTs matching `path` from now on. Read `.count` when needed. */ +export function countPuts(page: Page, path: RegExp): { count: number } { + const counter = { count: 0 }; + page.on('request', req => { + if (req.method() === 'PUT' && path.test(new URL(req.url()).pathname)) { + counter.count++; + } + }); + return counter; +} + +/** OperationResult failures come back as HTTP 200 with success:false, so check both. */ +export async function expectSuccess(response: Response): Promise { + expect(response.status(), `${response.url()} HTTP status`).toBeLessThan(400); + const body = await response.json(); + expect(body.success, `${response.url()} failed: ${body.message}`).toBe(true); + return body; +} + +/** Last week's Monday as yyyy-MM-dd, in the same local calendar the grid's dateFrom uses. */ +export function lastWeekMonday(): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + const sinceMonday = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - sinceMonday - 7); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export async function openDashboardLastWeek(page: Page): Promise { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const initial = waitForIndex(page); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await initial; + await waitForSpinner(page); + // Filtered on dateFrom, so a late current-week response cannot satisfy the wait. + const monday = lastWeekMonday(); + const lastWeek = waitForIndex(page, monday); + await page.locator('#backwards').click(); + const response = await lastWeek; + expect(response.request().postDataJSON().dateFrom, 'the grid must be on last week').toMatch(new RegExp(`^${monday}`)); + await waitForSpinner(page); +} + +/** The worker rendered at a grid position. Call it once per spec, to pick a worker. */ +export async function workerAtRow(page: Page, rowIndex: number): Promise { + const name = (await page.locator(`#firstColumn${rowIndex} .hours-info strong`).innerText()).trim(); + // A blank name would make every later lookup match vacuously. + expect(name, `row ${rowIndex} must name a worker`).toMatch(/^[^\s-]/); + return name; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** The grid row for a worker, found by name, never by position. */ +export function rowOf(page: Page, worker: string): Locator { + return page.locator('#main-header-text tr.mat-mdc-row').filter({ + has: page.locator('.hours-info strong', { + hasText: new RegExp(`^\\s*${escapeRegExp(worker)}\\s*$`), + }), + }); +} + +export function rowCheckbox(page: Page, worker: string): Locator { + return rowOf(page, worker).locator('td.mtx-grid-checkbox-cell input[type="checkbox"]'); +} + +/** A worker's day cell (.plan-container). day = column index, 0 = first visible day. */ +export function cellOf(page: Page, worker: string, day: number): Locator { + return rowOf(page, worker).locator(`.plan-container[id^="cell"][id$="_${day}"]`); +} + +/** The mtx-grid stamps getCellClass onto. */ +export function tdOf(page: Page, worker: string, day: number): Locator { + return cellOf(page, worker, day).locator('xpath=..'); +} + +export async function dialogTitle(page: Page): Promise<{ worker: string; date: string }> { + const title = page.locator('mat-dialog-container [mat-dialog-title]'); + await expect(title).toBeVisible({ timeout: 10000 }); + // " - ()", with the date on its own line. + const raw = (await title.innerText()).replace(/\s+/g, ' ').trim(); + return { + worker: raw.split(/\s+-\s+/)[0].trim(), + date: /(\d{2}\.\d{2}\.\d{4})/.exec(raw)?.[1] ?? '', + }; +} + +/** Opens a day, asserts the dialog is that worker's, and returns the date as dd.MM.yyyy. */ +export async function openDay(page: Page, worker: string, day: number): Promise { + const cell = cellOf(page, worker, day); + await expect(cell).toHaveCount(1); + await cell.scrollIntoViewIfNeeded(); + await cell.click(); + const title = await dialogTitle(page); + expect(title.worker, 'the dialog must belong to the worker the row was found by').toBe(worker); + expect(title.date).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + return title.date; +} + +/** Cancel with nothing persisted: no save and no reload. */ +export async function closeDayWithoutChange(page: Page): Promise { + await page.locator('#cancelButton').click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); +} + +/** Close after a reconcile made in the dialog: the table reloads the grid instead of saving. */ +export async function closeDayAfterLockChange(page: Page): Promise { + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#cancelButton').click(); + await reload; + await waitForSpinner(page); +} + +/** Two-step reconcile from the open dialog (§8.2). The dialog stays open, read-only. */ +export async function reconcileOpenDay(page: Page): Promise { + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmButton')).toBeVisible(); + const put = waitForPut(page, RECONCILE_PATH); + await page.locator('#reconcileConfirmButton').click(); + await expectSuccess(await put); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); +} + +/** Reconciles one day and returns its date (dd.MM.yyyy), with the grid reloaded. */ +export async function reconcileDay(page: Page, worker: string, day: number): Promise { + const date = await openDay(page, worker, day); + await reconcileOpenDay(page); + await closeDayAfterLockChange(page); + return date; +} + +/** Typed-word unlock of the open boundary day (§8.4). The dialog closes and the grid reloads. */ +export async function unlockOpenDay(page: Page): Promise { + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockConfirmButton').click(); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); +} + +export async function unlockDay(page: Page, worker: string, day: number): Promise { + await openDay(page, worker, day); + await unlockOpenDay(page); +} +``` + +- [ ] **Step 7: Playwright, glyphs and legend** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, LOCKED_TOOLTIP, openDashboardLastWeek, PROVENANCE, reconcileDay, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.1: the lock and seal glyphs, their tooltips, and the legend under the + * grid. The worker is grid row 5 at the start and is found by name after that. + * Day 4 (last week's Friday) becomes the boundary, so day 2 is cascade-locked and + * day 5 stays open. + */ +test.describe('Reconciled day lock: glyphs and legend', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('the boundary shows the seal, earlier days the lock, and the legend explains both', async ({ page }) => { + // Precondition, and a leak detector for the specs before this one: nothing + // locked is in view, so there is no legend. + await expect(page.locator('#lockLegend')).toHaveCount(0); + + const worker = await workerAtRow(page, 5); + await reconcileDay(page, worker, 4); + + // Boundary: the seal and no lock. Its tooltip is the provenance line. + const seal = cellOf(page, worker, 4).locator('.tp-day-glyph--seal'); + await expect(seal).toBeVisible(); + await expect(seal.locator('mat-icon')).toHaveClass(/tp-seal/); + await expect(cellOf(page, worker, 4).locator('.tp-day-glyph--lock')).toHaveCount(0); + await seal.hover(); + await expect(page.locator('.cdk-overlay-container .mat-mdc-tooltip-surface') + .filter({ hasText: PROVENANCE })).toBeVisible({ timeout: 10000 }); + + // Cascade: the lock and no seal. Its tooltip says what the day is. + const lock = cellOf(page, worker, 2).locator('.tp-day-glyph--lock'); + await expect(lock).toBeVisible(); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph--seal')).toHaveCount(0); + await lock.hover(); + await expect(page.locator('.cdk-overlay-container .mat-mdc-tooltip-surface') + .filter({ hasText: LOCKED_TOOLTIP })).toBeVisible({ timeout: 10000 }); + + // Above the boundary: no glyph at all. + await expect(cellOf(page, worker, 5).locator('.tp-day-glyph')).toHaveCount(0); + + // The legend appears with the first locked day and names both states. + await expect(page.locator('#lockLegend')).toBeVisible(); + await expect(page.locator('#lockLegendLocked')).toContainText(LOCKED_TOOLTIP); + await expect(page.locator('#lockLegendReconciled')).toContainText('Afstemt'); + + // Cleanup. With nothing locked, the legend goes away again. + await unlockDay(page, worker, 4); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph')).toHaveCount(0); + await expect(page.locator('#lockLegend')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 8: Verify what can be verified** + +Tests run only in CI. The plugin has no standalone Angular build either, because it +compiles inside the host. Two checks: +- `git diff` touches only the lines named above. +- `grep -c 'tp-day-glyph--\|lockLegend' eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html` + prints **5**. The five lines are the lock span, the seal span, the legend wrapper and + its two items. `grep -c` counts lines, and the glyph comments do not contain either + pattern. **Do not add markup to reach any other number.** + +- [ ] **Step 9: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts +git commit -m "feat(lock): lock and seal glyphs, tooltips and a legend in the grid" +``` + +--- + +## Task 10A: All lock styles in the host stylesheet (HOST REPO, same PR as Task 10) + +**Follows:** Task 10. +**Supersedes:** Task 10 Step 1 in full. The block below replaces the block Task 10 +appended. If Task 10 has not run yet, append this block instead. Task 10 Step 2 (the +branch) stands, and Step 3 here commits on that branch. + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-angular-frontend` +- Modify: `eform-client/src/scss/styles.scss`. Place the block after the + `.red-background .plan-container` block, which ends at :358, where Task 10 put its + own block. + +**Interfaces:** +- Consumes: the class names written by 9A, 11A, 11B and 12A. +- Produces: the tokens `--tp-locked-bg`, `--tp-locked-hatch`, `--tp-preview-hatch`, + `--tp-seal-ink` and `--tp-select-col-width`, and every class in the table below. + +**Where each rule lives.** The plugin adds **no** SCSS. Every rule below extends Task +10's host PR. The plugin does have component `.scss` files with +`ViewEncapsulation.None`, but per CLAUDE.md and this plan none of these rules go there. + +| Rule group | Extends Task 10 by | Used by | +|---|---|---| +| Tokens (`:root` + `body.theme-dark`) | **correcting** Task 10's dark block; adding `--tp-preview-hatch` | all | +| `.locked-background` / `.reconciled-background` | Task 10's rules plus text colour (`.plan-text` *and* `.comment`) and a glyph-aware dim | Task 9 | +| `.tp-day-glyph*` | new | 9A | +| `.tp-seal` | new: filled Rounded seal on every theme | 9A, 11A, 11B | +| `.tp-lock-legend*` | new | 9A | +| `.tp-day-header-btn` | new | 12A | +| `.tp-preview-*`, `.tp-reconcile-scope*` | new | 12A | +| `.time-dashboard` selection column | new: sticky checkbox column; Name shifted right | 12A | +| `.tp-dialog-footer`, `.tp-footer-*`, `.tp-unlock-word` | new | 11A, 11B | + +**What this block changes in Task 10's rules.** +1. **The dark selector.** It keys off `body.theme-dark` (fact 7). Task 10 used + `prefers-color-scheme` and `data-theme`, which this app does not use. +2. **Text in dark mode.** The locked ground `#262B29` sits under the state classes' + hard-coded text colour: `#0F1316` on green, and `--text-header` on grey and white. + That applies to `.plan-text` and also to `.comment`, which inherits + `#0F1316 !important` from `.X-background .plan-container`. On the neutral lock + ground the state text colour is only decoration anyway, because colour is never + load-bearing (§8.1). The text becomes `--tp-text`, set on the container, on + `.plan-text` and on `.comment`. +3. **The dim.** Task 10 dims the whole `.plan-content`, which would also dim the lock + glyph. The dim now skips the glyph. + +**The seal on every theme (fact 6).** Theme-workspace's rule +`body.theme-workspace .mat-icon.filled` has specificity (0,3,1) and forces Outlined, +which is loaded with FILL 0. `body .mat-icon.material-symbols-rounded.filled.tp-seal` +has specificity (0,4,1). It wins regardless of source order, and both sides use +`!important`. + +**The selection column (fact 13).** The checkbox column is made sticky, and the pinned +Name column moves right by the checkbox column's 60px. I did not unpin Name instead. +In a grid a week or a month wide, Name is the only thing on screen that says whose row +a cell belongs to, and 12A's preview is read row by row. + +**Palette check.** +- No yellow outline, which belongs to `.highlight-cell`. +- No blue, which belongs to `.setting-ico.active`. +- The preview uses a *dashed* outline in `--tp-seal-ink` (green-teal). That is the + same channel as `.highlight-cell`, but in another colour and texture, and it sits on + `.plan-container` rather than on the ``. + +**Known spec tension, left as the spec says.** §8.1 gives a locked cell the cursor +`not-allowed`, but §8.5 says the cell still opens a read-only dialog when clicked. + +- [ ] **Step 1: Replace Task 10's block with this one** + +```scss +/* --------------------------------------------------------------------------- + Time planning: reconciled ("Afstemt") day lock. Plugin templates: + time-plannings-table, time-plannings-container, workday-entity-dialog. + + Four independent channels carry the state (texture, glyph, cursor and tooltip + text), so colour is never load-bearing. The 3px right border on a reconciled + cell draws the staircase boundary down the grid. + + Theme-agnostic on purpose: body.theme-eform rules do not apply under + body.theme-workspace, and these must read on both. Dark mode in this app is the + body.theme-dark class (full-layout.component.ts), NOT prefers-color-scheme. + Palette: no yellow outline (.highlight-cell) and no blue (.setting-ico.active). + --------------------------------------------------------------------------- */ +:root { + --tp-locked-bg: #E4E7E4; + --tp-locked-hatch: rgba(22, 33, 30, 0.055); + --tp-preview-hatch: rgba(47, 93, 80, 0.16); + --tp-seal-ink: #2F5D50; +} + +body.theme-dark { + --tp-locked-bg: #262B29; + --tp-locked-hatch: rgba(230, 234, 232, 0.06); + --tp-preview-hatch: rgba(127, 209, 185, 0.18); + --tp-seal-ink: #7FD1B9; +} + +// A mixin, not a custom property holding the gradient. A custom property that +// contains var() is resolved where it is declared (:root), so the body.theme-dark +// hatch override would never reach it. +@mixin tp-locked-texture { + background: repeating-linear-gradient(135deg, + var(--tp-locked-hatch) 0 2px, transparent 2px 6px), + var(--tp-locked-bg) !important; +} + +.locked-background .plan-container { + @include tp-locked-texture; + /* Beats the shared `.plan-container, .progress-container { cursor: pointer }` + rule by specificity. Scoped by .locked-background, so the avatar and progress + circle are unaffected. */ + cursor: not-allowed !important; +} + +.reconciled-background .plan-container { + background: var(--tp-locked-bg) !important; + border-right: 3px solid var(--tp-seal-ink) !important; + cursor: default !important; +} + +/* The state classes (green, red, grey, white) hard-code a text colour for their own + light grounds, on .plan-text and on the container itself, which .comment + inherits. On the neutral lock ground that colour is decoration, and in dark mode + #0F1316 on --tp-locked-bg cannot be read. Same specificity as the state rules and + later in the file, so these win. */ +.locked-background .plan-container, +.reconciled-background .plan-container { + color: var(--tp-text) !important; + + .plan-text, + .plan-text strong, + .plan-text span, + .plan-text mat-icon, + .comment { + color: var(--tp-text) !important; + } +} + +/* Dimmed, not hidden: people read closed days constantly. The glyph is excluded, + so the one element that explains the dimming is not dimmed with it. */ +.locked-background .plan-content > :not(.tp-day-glyph) { + opacity: 0.72; +} + +/* Glyphs (9A). The lock is the last child of .plan-content; a flex column plus + margin-top:auto puts it bottom-left. The seal is the last .plan-icons item, + top-right. Neither is absolutely positioned: .neutral-icon forces + position:relative and top:5px with !important. */ +.locked-background .plan-content { + display: flex; + flex-direction: column; +} + +.tp-day-glyph { + display: inline-flex; + align-self: flex-start; + + mat-icon { + color: var(--tp-seal-ink) !important; + } +} + +.tp-day-glyph--lock { + margin-top: auto; +} + +/* The filled seal, on every theme. Outlined is loaded with FILL fixed at 0 + (index.html), and body.theme-workspace forces every .mat-icon onto Outlined with + !important (_workspace-mat-overrides.scss). At (0,4,1) this beats that rule's + (0,3,1) whatever the source order. */ +body .mat-icon.material-symbols-rounded.filled.tp-seal { + font-family: 'Material Symbols Rounded' !important; + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important; +} + +/* Legend under the grid (9A). Shown only while a locked day is on screen. */ +.tp-lock-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-top: 8px; + padding: 8px 4px; + color: var(--tp-text); + font-size: 13px; +} + +.tp-lock-legend__item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tp-lock-legend__swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 22px; + border: 1px solid var(--tp-border); + border-radius: 3px; + + .neutral-icon { + top: 0 !important; + font-size: 16px !important; + width: 16px; + height: 16px; + color: var(--tp-seal-ink) !important; + } +} + +.tp-lock-legend__swatch--locked { + @include tp-locked-texture; +} + +.tp-lock-legend__swatch--reconciled { + background: var(--tp-locked-bg); + box-shadow: inset -3px 0 0 var(--tp-seal-ink); +} + +/* Clickable header of a past day column (12A). It only starts a preview, so the + affordance stays quiet: a dotted underline on hover or keyboard focus. */ +.tp-day-header-btn { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + text-decoration: underline dotted transparent; + text-underline-offset: 3px; + + &:hover, + &:focus-visible { + text-decoration-color: var(--tp-seal-ink); + } +} + +/* Bulk reconcile preview (12A): what WILL lock, drawn in place before the commit. + Dashed because it is not true yet. It is layered over the cell's own state colour + (background-image only), so the day stays recognisable underneath. Same + specificity as `.X-background .plan-container`, placed later, so it wins. */ +.plan-container.tp-preview-lock { + background-image: repeating-linear-gradient(135deg, + var(--tp-preview-hatch) 0 2px, transparent 2px 6px) !important; + outline: 2px dashed var(--tp-seal-ink); + outline-offset: -3px; +} + +.plan-container.tp-preview-boundary { + box-shadow: inset -3px 0 0 var(--tp-seal-ink); +} + +.plan-container.tp-preview-skip { + opacity: 0.55; +} + +.tp-preview-skip-label { + display: inline-block; + margin-top: 2px; + padding: 0 6px; + border-radius: 4px; + background: var(--tp-locked-bg); + color: var(--tp-text); + font-size: 11px; + font-weight: 600; +} + +/* Bulk reconcile scope bar (12A), between the toolbar and the grid. */ +.tp-reconcile-scope { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--tp-border); + border-left: 3px solid var(--tp-seal-ink); + border-radius: 8px; + background: var(--tp-td-bg); + color: var(--tp-text); + + .neutral-icon { + top: 0 !important; + color: var(--tp-seal-ink) !important; + } +} + +.tp-reconcile-scope__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; +} + +/* Bulk selection column (12A). mtx-grid puts its checkbox column in front of the + pinned Name column but never makes it sticky, so on a sideways scroll the + checkboxes would slide under Name. Pin it too, and move Name right by its width. + Unpinning Name instead was rejected: in a wide grid, Name is the only thing that + says whose row a cell belongs to. */ +.time-dashboard { + --tp-select-col-width: 60px; /* mtx-grid's own .mtx-grid-checkbox-cell width */ + + th.mtx-grid-checkbox-cell, + td.mtx-grid-checkbox-cell { + position: sticky; + left: 0; + z-index: 2; + box-sizing: border-box; + width: var(--tp-select-col-width); + min-width: var(--tp-select-col-width); + max-width: var(--tp-select-col-width); + background: var(--tp-td-bg, #FFF); + } + + /* CDK and mtx-grid write an inline left:0 on the sticky Name cells. A stylesheet + !important beats a non-important inline style. */ + .mat-column-siteName.mat-table-sticky-left { + left: var(--tp-select-col-width) !important; + } +} + +/* Day dialog footer (11A, 11B). It morphs in place between modes. */ +.tp-dialog-footer { + flex-wrap: wrap; + gap: 12px; +} + +.tp-footer-status, +.tp-footer-confirm, +.tp-footer-note { + flex: 1 1 auto; + color: var(--tp-text); +} + +.tp-footer-status { + display: flex; + flex-direction: column; + gap: 2px; + + > span { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .neutral-icon { + top: 0 !important; + color: var(--tp-seal-ink) !important; + } +} + +.tp-footer-note { + font-size: 13px; + opacity: 0.8; +} + +.tp-unlock-word { + width: 12ch; + height: 40px; + padding: 0 12px; + border: 1px solid var(--tp-border); + border-radius: var(--rounded-full); + background: var(--tp-td-bg); + color: var(--tp-text); + font: inherit; + letter-spacing: 0.08em; + text-transform: uppercase; /* display only; the comparison ignores case */ +} +``` + +- [ ] **Step 2: Verify** + +This SCSS compiles only inside `ng build`, which CI runs. Locally, check that: +- `git diff eform-client/src/scss/styles.scss` shows one contiguous block after :358; +- `grep -n "prefers-color-scheme" eform-client/src/scss/styles.scss` prints nothing. + +In the browser (step 6 of the development cycle), check on **both** `theme-eform` and +`theme-workspace`: +- the seal renders filled; +- the checkbox column stays put while the grid scrolls sideways. + +- [ ] **Step 3: Commit on Task 10's branch** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-frontend +git checkout feat/reconciled-day-lock-styles +git add eform-client/src/scss/styles.scss +git commit -m "feat(timeplanning): lock glyphs, legend, bulk preview, selection column and dialog footer styles" +``` + +This commit goes into Task 10's PR. Three FOSSA checks fail on every +`eform-angular-frontend` PR and do not gate the merge. + +**Merge order.** The plugin's CI checks out host `stable`. The Playwright specs assert +classes, ids and text, not visuals, so they pass either way. Merge this host PR before +the plugin PR anyway, so that the browser check at step 6 of the development cycle +shows the real look. + +--- + +## Task 11A: Two-step reconcile in the dialog footer, read-only in place (§8.2) + +**Follows:** Task 11. +**Supersedes:** +- **Task 11 Step 3, the banner.** It is replaced by two hints with fixed ids. The + bound `[helpId]` version does not fail the wiring spec; it escapes it, so its ids + would never be checked against the registry or the exhaustive hint list (fact 9). +- **Task 11 Step 4, the footer.** `btn-secondary` fails CI's "Button conventions" + step (fact 8). The footer also **drops Task 11's direct `#unlockButton`**: unlock + returns in 11B with the typed word. Nothing is added here only to be deleted. +- **Task 11 Step 5, the handlers.** They close the dialog with `this.data`, which the + table then saves on a day that is now locked. +- **Task 11 Step 6, the directory `git add`.** +- **Task 9 Step 2, `onDayColumnClick`,** merged below. + +**Files:** +- Modify: `.../time-planning-actions/workday-entity/workday-entity-dialog.component.ts` +- Modify: `.../time-planning-actions/workday-entity/workday-entity-dialog.component.html` (the hint block ~:461 and `mat-dialog-actions` :494-514) +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` (`onDayColumnClick` :458-485) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts` + +**Interfaces:** +- Consumes: + - `reconcileDay(id)` (Task 8) + - the dialog data fields and `isLocked` (Task 11 Steps 1-2) + - `dayKey` and `formatReconciledProvenance` (9A) + - the footer classes and `tp-seal` (10A) + - the keys `Reconcile day`, `reconcileDayConfirm`, `reconcileNeedsSave`, `Cancel` and `Save` (13A) +- Produces: + - `footerMode`, `lockStateChanged`, `lockRequestInFlight` and `setLockRequestInFlight()` + - the **close contract:** after any close, the table reads `lockStateChanged` and + reloads the grid instead of saving + +**Design.** +- **Why the dialog.** It is the one place that already shows whose day, which date + and which hours. The footer morphs in place instead of opening a second modal, + which would cover exactly that context. +- **After a successful PUT the dialog stays open.** The same dialog turns read-only, + and the provenance line `Afstemt kl. ` sits where the actions were. + There is no "by whom", because ReconciledBy is a declared non-goal. Until the dialog + closes, the client clock stands in for `ReconciledAt`. The reload after the close + brings the stored server value, which is what every later open shows. +- **Unsaved edits block reconcile** (kept at review). Reconcile writes only the flag, + not the form, so reconciling a dirty form would freeze a view of unsaved values as + if they were the sealed figures, and the edits would be lost. A dirty form shows a + note in place of the button. +- **Closing mid-PUT is blocked.** While the PUT is in flight, `dialogRef.disableClose` + is `true`. Otherwise Esc or a backdrop click could close the dialog before + `lockStateChanged` is set, and the grid would draw a sealed day as open. +- **Every close path reloads.** The dialog can close through Cancel, Esc or the + backdrop. Whichever path ends a dialog in which a reconcile happened, the grid must + reload and must not save. The table reads `lockStateChanged` from the component + instance it captured when it opened the dialog (fact 5). + +- [ ] **Step 1: The banner** (replaces Task 11 Step 3) + +Beside the `dayCell.futureDisabled` hint (:461-465): + +```html + + + +``` + +The comment deliberately never writes an id attribute with a quoted value. The wiring +spec reads comments as markup (fact 9), and 13A Step 5 greps for exactly that. + +- [ ] **Step 2: The footer** (replaces Task 11 Step 4 and the original :494-514; 11B replaces it again) + +```html + + +``` + +The buttons run Cancel, then Reconcile (quiet), then Save (primary). Save stays at the +right edge, under the cursor, where it has always been. + +- [ ] **Step 3: The handlers** (replace Task 11 Step 5) + +In `workday-entity-dialog.component.ts`, add these imports: + +```ts +import {format} from 'date-fns'; +import {dayKey, formatReconciledProvenance} from '../../day-lock.util'; +``` + +Do **not** add Task 11's `canReconcile`, `onReconcile()` or `onUnlock()`. Add: + +```ts + // ---- Reconcile footer (spec §8.2) ---------------------------------------- + + /** The footer morphs in place instead of opening a second modal. */ + footerMode: 'actions' | 'confirmReconcile' | 'confirmUnlock' = 'actions'; + + /** + * Set once a reconcile or unlock has reached the server. After the close, whatever + * its path (Cancel, Esc, backdrop), the table reads this and reloads the grid + * instead of treating the close payload as a save. + */ + lockStateChanged = false; + + /** True while a reconcile or unlock PUT is in flight. */ + lockRequestInFlight = false; + + /** Captured at construction so it can be restored after the PUT. */ + private readonly defaultDisableClose = this.dialogRef.disableClose; + + /** + * While the PUT is in flight the dialog cannot be dismissed. Otherwise Esc or a + * backdrop click would close it before lockStateChanged is set, and the grid would + * go on drawing a day the server has just sealed as open. + */ + private setLockRequestInFlight(inFlight: boolean): void { + this.lockRequestInFlight = inFlight; + this.dialogRef.disableClose = inFlight || this.defaultDisableClose; + } + + /** I2: today and future days stay open so time can still be registered. */ + get canReconcile(): boolean { + const day = dayKey(this.data.planningPrDayModels.date); + return !!this.data.planningPrDayModels.id && day !== null && day < dayKey(new Date()); + } + + /** "Afstemt 14.09.2026 kl. 10:32", shown where the actions were. */ + get reconciledProvenance(): string { + return formatReconciledProvenance( + this.data.planningPrDayModels.reconciledAt, this.datePipe, this.translateService); + } + + get dayLabel(): string { + return this.datePipe.transform(this.data.planningPrDayModels.date, 'dd.MM.yyyy') ?? ''; + } + + onReconcileStart(): void { + if (!this.isLocked && this.canReconcile && !this.workdayForm.dirty) { + this.footerMode = 'confirmReconcile'; + } + } + + onReconcileCancel(): void { + this.footerMode = 'actions'; + } + + onReconcileConfirm(): void { + if (this.lockRequestInFlight) { + return; + } + // An edit made while the confirm was showing would be frozen unsaved. Go back to + // the actions, where the "save first" note explains why. + if (this.workdayForm.dirty) { + this.footerMode = 'actions'; + return; + } + this.setLockRequestInFlight(true); + this.planningsService.reconcileDay(this.data.planningPrDayModels.id).subscribe({ + next: result => { + if (result && result.success) { + this.applyReconciledInPlace(); + } else { + // ApiBaseService has already shown the server's message as a toast. + this.footerMode = 'actions'; + } + this.setLockRequestInFlight(false); + }, + error: () => { + this.setLockRequestInFlight(false); + this.footerMode = 'actions'; + }, + }); + } + + /** + * Turns this open dialog read-only instead of closing it (spec §8.2). The server + * stamped ReconciledAt a moment ago. The client clock stands in for it until the + * close, and the reload that follows brings the stored value for every later open. + */ + private applyReconciledInPlace(): void { + const day = this.data.planningPrDayModels; + day.reconciled = true; + day.reconciledAt = format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"); + this.data.isReconciled = true; + // Only open days above the boundary offer reconcile, so this day IS the new boundary. + this.data.lockedThrough = day.date; + this.isLocked = true; + // Task 11's setDisabled guard stops every later cascade call from re-enabling a control. + this.workdayForm.disable({emitEvent: false}); + this.lockStateChanged = true; + this.footerMode = 'actions'; + } +``` + +`lockStateChanged` is set **before** `setLockRequestInFlight(false)` re-enables Esc and +the backdrop. That ordering is what the guard exists for. + +- [ ] **Step 4: The table's side of the close contract** (replaces Task 9 Step 2) + +The final `onDayColumnClick` in `time-plannings-table.component.ts`: + +```ts + onDayColumnClick(row: any, field: string): void { + const siteId = row.siteId; + const cellData = R.clone(row.planningPrDayModels[field]); + this.timePlanningPnSettingsService.getAssignedSite(siteId).subscribe(result => { + if (result && result.success) { + const dialogRef = this.dialog.open(WorkdayEntityDialogComponent, { + data: { + planningPrDayModels: cellData, + assignedSiteModel: result.model, + tags: row.tags ?? [], + isLocked: this.isDayLocked(row, field), + isReconciled: this.isDayReconciled(row, field), + lockedThrough: row.lockedThrough ?? null, + }, + minWidth: 1024, + minHeight: 500, + maxWidth: '95vw', + maxHeight: '95vh', + panelClass: 'time-planning-dialog' + }); + // Captured now, because MatDialogRef sets componentInstance to null on close, + // and every close path must be able to report a reconcile or unlock that has + // already reached the server. + const dialog = dialogRef.componentInstance; + dialogRef.afterClosed().subscribe((data: any) => { + if (dialog?.lockStateChanged) { + // Already persisted by the dialog's own PUT. Never fall through to + // updatePlanning: the day is now locked, and the save would be refused. + this.pendingHighlight = { siteId, field }; + this.highlightApplied = false; + this.waitingForFreshData = true; + this.timePlanningChanged.emit(null); + return; + } + if (data !== '' && data !== undefined) { + this.pendingHighlight = { siteId, field }; + this.highlightApplied = false; + this.waitingForFreshData = true; + this.planningsService.updatePlanning(data.planningPrDayModels, data.planningPrDayModels.id).subscribe(result => { + if (result && result.success) { + this.timePlanningChanged.emit(data); + } + }); + } + }); + } + }); + } +``` + +The container's `onTimePlanningChanged` ignores its argument and calls +`getPlannings()`, so emitting `null` is enough. + +- [ ] **Step 5: Playwright, two-step confirm and read-only in place** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayAfterLockChange, closeDayWithoutChange, countPuts, openDashboardLastWeek, openDay, + PLANNING_PUT_PATH, PROVENANCE, RECONCILE_PATH, reconcileOpenDay, tdOf, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** Spec §8.2. The worker is grid row 6 at the start and is found by name after that. */ +test.describe('Reconciled day lock: dialog confirm', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('reconcile takes a second click in the same footer, then the dialog stays open read-only', async ({ page }) => { + const worker = await workerAtRow(page, 6); + const reconciles = countPuts(page, RECONCILE_PATH); + + const date = await openDay(page, worker, 3); + await expect(page.locator('#saveButton')).toBeVisible(); + await expect(page.locator('#CommentOffice')).toBeEnabled(); + + // The first click morphs the footer. It commits nothing and opens no second modal. + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmText')).toContainText(worker); + await expect(page.locator('#reconcileConfirmText')).toContainText(date); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + expect(reconciles.count).toBe(0); + + // Backing out restores the normal footer, and still nothing is sent. + await page.locator('#reconcileCancelButton').click(); + await expect(page.locator('#saveButton')).toBeVisible(); + expect(reconciles.count).toBe(0); + + // The second click commits. The SAME dialog turns read-only, with the provenance + // line where the actions were. + await reconcileOpenDay(page); + expect(reconciles.count).toBe(1); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await expect(page.locator('#CommentOffice')).toBeDisabled(); + + // Closing reloads the grid and does not save. + await closeDayAfterLockChange(page); + await expect(tdOf(page, worker, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 4)).not.toHaveClass(/locked-background|reconciled-background/); + + // A later open shows the server's stored ReconciledAt. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await closeDayWithoutChange(page); + + // Cleanup. + await unlockDay(page, worker, 3); + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + }); + + test('a day with unsaved edits offers no reconcile, and Cancel writes nothing', async ({ page }) => { + const worker = await workerAtRow(page, 6); + await openDay(page, worker, 1); + await expect(page.locator('#reconcileButton')).toBeVisible(); + + await page.locator('#CommentOffice').fill('reconcile-guard'); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconcileNeedsSave')).toBeVisible(); + + // Cancel closes with '': no save, no reconcile, no write of any kind. + const writes = countPuts(page, PLANNING_PUT_PATH); + await closeDayWithoutChange(page); + await page.waitForTimeout(1000); + expect(writes.count, 'Cancel must not send a save or a reconcile').toBe(0); + }); +}); +``` + +- [ ] **Step 6: Verify the button row locally** + +This is the same checker CI runs, pointed at the source tree. It is a lint script, not +a test runner: + +```bash +node /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client/scripts/check-button-conventions.js \ + /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +``` + +Expected output: `Button conventions: OK`. + +- [ ] **Step 7: Task 11's own commit, staged by name** (replaces Task 11 Step 6) + +Run this only if Task 11's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html +git commit -m "feat(lock): open locked days read-only, with reconcile and unlock" +``` + +- [ ] **Step 8: Commit 11A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts +git commit -m "feat(lock): two-step reconcile in the day dialog, read-only in place" +``` + +--- + +## Task 11B: Typed-word unlock and "free this day first" (§8.4) + +**Follows:** Task 11A. +**Supersedes:** 11A Step 2 in full. This task has the final `mat-dialog-actions`. + +**Files:** +- Modify: `.../workday-entity/workday-entity-dialog.component.ts` +- Modify: `.../workday-entity/workday-entity-dialog.component.html` (`mat-dialog-actions`) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts` + +**Interfaces:** +- Consumes: + - `unreconcileDay(id)` (Task 8) + - `data.lockedThrough` (Task 9, merged in 11A) + - `dayKey` (9A) + - `footerMode`, `lockStateChanged`, `lockRequestInFlight` and `setLockRequestInFlight()` (11A) + - the keys `UNLOCK`, `unlockTypeWordPrompt`, `unlockFreeFirst` and `Unlock` (13A) +- Produces: `isBoundaryDay`, `freeFirstDate`, `unlockWord`, `unlockWordMatches`, + `onUnlockStart()`, `onUnlockCancel()` and `onUnlockConfirm()`. + +**Only the boundary day offers unlock.** +- **The boundary** is the row's `lockedThrough`. Unlocking it moves the line back one + notch. +- **Why no other day.** The lock is derived: every day at or before `lockedThrough` is + locked. Unlocking a lower day would need an editable day under a later sealed one, + and a derived lock cannot represent that. The server rejects it (Task 4). +- **What a lower day shows instead.** It names the day to free first: + `Låst, fordi er afstemt. Lås op først.` That replaces a disabled + control with no explanation. +- **A reconciled day that is not the boundary** (an older month-end below a newer + one) shows both its provenance line and that "free first" line. + +**Why sealing takes a click and unsealing takes a word.** The two directions do not +carry the same risk. +- **Sealing is cheap to undo and fails safe.** At worst a period is frozen a day + early, and one unlock moves the line back. +- **Unsealing reopens settled figures.** They have usually already gone to payroll or + been checked against it. Unsealing exposes them again to edits, and to the + recalculation paths that Task 5 made skip locked days. +- **Nothing else guards the reverse direction.** Any web user may reconcile (§8.6). + The reverse-order rule plus this confirmation is the only safeguard on unlocking. +- **A click can be made by reflex; a word cannot.** A second click can be made without + thinking, because the confirm appears where the first button was. Typing a word + cannot, and it makes the user read the sentence that says what is about to happen. + +**How the word is checked.** +- **Case and whitespace are ignored.** The friction should be the word, not the Shift + key. +- **The word is localized:** `LÅS OP` in Danish, `UNLOCK` in English. +- **Untranslated locales still work.** The key is `UNLOCK`, and its value in those + locales is `UNLOCK`, so a missing translation still gives a word the user can type. + +- [ ] **Step 1: The final footer** (replaces 11A Step 2) + +```html + + +``` + +- [ ] **Step 2: The handlers** + +In `workday-entity-dialog.component.ts`, add `ElementRef` to the `@angular/core` +import. `ViewChild` is already there: + +```ts +import {Component, ElementRef, OnInit, TemplateRef, ViewChild, + inject, OnDestroy +} from '@angular/core'; +``` + +Add: + +```ts + // ---- Unlock (spec §8.4) ------------------------------------------------------ + + readonly unlockWordCtrl = new FormControl('', {nonNullable: true}); + + @ViewChild('unlockWordInput') private unlockWordInput?: ElementRef; + + /** Only the boundary day offers unlock. It moves the line back one notch. */ + get isBoundaryDay(): boolean { + return this.isLocked + && this.data.isReconciled === true + && dayKey(this.data.planningPrDayModels.date) === dayKey(this.data.lockedThrough); + } + + /** The day to free first, for every other locked day: the row's boundary. */ + get freeFirstDate(): string { + return this.datePipe.transform(this.data.lockedThrough, 'dd.MM.yyyy') ?? ''; + } + + /** The localized word to type. The key doubles as its own fallback in untranslated locales. */ + get unlockWord(): string { + return this.translateService.instant('UNLOCK'); + } + + /** Case and spacing are not the friction; the word is. */ + get unlockWordMatches(): boolean { + const norm = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleUpperCase(); + return norm(this.unlockWordCtrl.value) === norm(this.unlockWord); + } + + onUnlockStart(): void { + if (!this.isBoundaryDay) { + return; + } + this.unlockWordCtrl.setValue(''); + this.footerMode = 'confirmUnlock'; + // The input exists only once this change-detection pass has rendered the mode. + setTimeout(() => this.unlockWordInput?.nativeElement.focus()); + } + + onUnlockCancel(): void { + this.unlockWordCtrl.setValue(''); + this.footerMode = 'actions'; + } + + onUnlockConfirm(): void { + if (!this.unlockWordMatches || this.lockRequestInFlight) { + return; + } + this.setLockRequestInFlight(true); + this.planningsService.unreconcileDay(this.data.planningPrDayModels.id).subscribe({ + next: result => { + if (result && result.success) { + // The day is editable again, but this form was built locked. The only way + // back to a form whose enable/disable cascade ran from a clean start is to + // close and reopen from a reloaded grid. lockStateChanged is set before + // the guard lifts, so no close path can miss it. + this.lockStateChanged = true; + this.setLockRequestInFlight(false); + this.dialogRef.close(); + return; + } + // On failure (a newer boundary appeared meanwhile, say) stay in this mode. + // ApiBaseService has already toasted the server's message, which names the + // day to free first. + this.setLockRequestInFlight(false); + }, + error: () => { + this.setLockRequestInFlight(false); + }, + }); + } +``` + +- [ ] **Step 3: Playwright, typed-word unlock** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayWithoutChange, expectSuccess, lastWeekMonday, openDashboardLastWeek, openDay, reconcileDay, + tdOf, UNLOCK_WORD, UNRECONCILE_PATH, waitForIndex, waitForPut, waitForSpinner, workerAtRow, +} from './reconcile-helpers'; + +/** Spec §8.4. The worker is grid row 9 at the start and is found by name after that. */ +test.describe('Reconciled day lock: unlock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('only the boundary offers unlock, earlier days name it, and unlocking takes the word', async ({ page }) => { + const worker = await workerAtRow(page, 9); + const boundaryDate = await reconcileDay(page, worker, 3); + + // A day below the boundary names the day to free first and offers no unlock. + await openDay(page, worker, 1); + await expect(page.locator('#lockedFreeFirstText')) + .toHaveText(`Låst, fordi ${boundaryDate} er afstemt. Lås ${boundaryDate} op først.`); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await expect(page.locator('#saveButton')).toHaveCount(0); + await closeDayWithoutChange(page); + + // The boundary: its provenance, no "free first" line, and an unlock gated by the word. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + await expect(page.locator('#lockedFreeFirst')).toHaveCount(0); + + await page.locator('#unlockButton').click(); + await expect(page.locator('#unlockPrompt')).toContainText(UNLOCK_WORD); + await expect(page.locator('#unlockWordInput')).toBeFocused(); + const confirm = page.locator('#unlockConfirmButton'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill('LÅS'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill(' lås op '); + await expect(confirm).toBeEnabled(); + + // Backing out keeps the day reconciled. + await page.locator('#unlockCancelButton').click(); + await expect(page.locator('#unlockWordInput')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + + // Enter in the field confirms. It must not submit the surrounding form, whose first + // button is the version-history button in the title. + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockWordInput').press('Enter'); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); + await expect(page.locator('app-version-history-modal')).toHaveCount(0); + + // The line moved back: both days are open again. + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 4: Verify the button row locally** + +```bash +node /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client/scripts/check-button-conventions.js \ + /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +``` + +Expected output: `Button conventions: OK`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts +git commit -m "feat(lock): unlock the boundary day with a typed word; name the day to free first" +``` + +--- + +## Task 12A: Bulk reconcile with an in-place preview (§8.3) + +**Follows:** Task 12. +**Supersedes:** +- **Task 12 Step 1** in full: the grid inputs, `onRowSelected`, `stopRowClick`, the + day-cell line, and the `ngOnChanges` re-emit. +- **Task 12 Step 2:** the toolbar markup. +- **Task 12 Step 3:** the container logic. +- **Task 12 Step 4:** the directory `git add`. +- **9A Step 3:** its `ngOnChanges` addition, merged below. + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` +- Modify: `.../time-plannings-table/time-plannings-table.component.html` +- Modify: `.../time-plannings-container/time-plannings-container.component.ts` +- Modify: `.../time-plannings-container/time-plannings-container.component.html` +- Modify: `.../time-plannings-container/time-plannings-container.component.spec.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts` + +**Interfaces:** +- Consumes: + - `reconcileThrough(date, siteIds)` (Task 8) + - `ReconcileThroughResultModel` (8A) + - `isDayLocked` (Task 9) + - `dayKey`, `buildReconcilePreview` and `ReconcilePreview` (9A, unchanged) + - classes from 10A + - keys from 13A +- Produces: + - table: `@Input reconcilePreview`, plus the outputs `selectionChanged` (Task 12's + name, kept), `selectionReset` and `reconcileDateRequested` + - container: `onReconcileDateChanged`, `onSelectionChanged`, `onSelectionReset`, + `confirmReconcileThrough` and `cancelReconcilePreview` + +**Scope = target date × set of workers.** +- **The date** comes from clicking a past day-column header (spec §8.3, "Selected + column") or from the toolbar date field. Both only **preview**. +- **The commit** is one button, in a scope bar under the toolbar. The bar names the + worker count and the target date. +- **The workers** are the ticked rows. With nothing ticked, they are every worker + currently visible. + +**The preview.** +- **The rules** mirror Task 4, per worker, from `row.planningPrDayModels` and + `row.lockedThrough` (see `buildReconcilePreview`, 9A). +- **A row already at or past the target is shown as skipped.** It gets a + "Springes over" label and is dimmed. +- **A locked row is drawn from its boundary up to its landing day.** Every cell after + its existing boundary, up to and including that day, gets `tp-preview-lock`, and the + landing day also gets `tp-preview-boundary`. +- **A row the preview cannot draw is not sent.** +- **The preview must be on screen** (kept at review). The date field's `min` is the + first visible day. Its `max` is the earlier of the last visible day and yesterday + (I2). §8.3's preview needs the cells on screen. +- **The classes go on the template, not `getCellClass`**, because mtx-grid's `colClass` + pipe is pure (fact 1). + +**A preview never widens silently.** An earlier revision cleared the ticked rows on any +reload and then rebuilt the preview. The empty selection then fell back to "every +visible worker", so a day save, the assigned-site dialog, Reload, a tag filter, the +resigned toggle or a language switch would quietly move the scope from N ticked +workers to all of them. A mistaken commit would then cost one typed-word unlock per +worker. +- **Nothing ticked:** the preview is rebuilt on the new rows. The scope is still + "everyone visible", so it does not widen. +- **Rows were ticked:** when the grid drops the ticked rows, the preview is + **cancelled**, never rebuilt. +- **Reloads** (`applyPlannings`) decide from `hadSelection`. +- **The grid's own resets** (language switch, new columns) arrive as a separate + `selectionReset` output, and the container cancels. +- **Unticking the last row by hand** is the one case that goes back to "everyone + visible". It is a deliberate spec rule, and the scope bar's count changes where the + user can see it. + +**The two mtx-grid gotchas, handled at the source.** +1. **A click selects the row.** `[disableRowClickSelection]="true"` makes `_selectRow` + skip the selection while still emitting `rowClick` (fact 3). Task 12 fixed only the + day cell; the Name column's click (the assigned-site dialog) would still have + cleared the batch. Rows are selected by their checkbox only. 10A makes that column + sticky. +2. **The selection is rebuilt empty, silently.** mtx-grid does this on *any* input + change (fact 2). + - The table emits `selectionReset` from `ngOnChanges` (`timePlannings`) and from + `updateTableHeaders` (`[columns]` and `[headerTemplate]`), but only when a + selection was active. + - The container clears its scope itself inside `applyPlannings`, before change + detection runs. The scope bar therefore never changes after it has been checked, + so there is no NG0100 in a dev build. + +**The result toast.** It reads the corrected per-site model: +- `applied`; +- skipped = `skippedAlreadyFurtherForward.length + skippedNoRegistration.length`; +- an "Uændret: n" suffix when `alreadyReconciledSiteIds` is not empty. This is kept + at review, because it reports AlreadyReconciled. + +It is a success toast when `applied > 0` and a warning otherwise. + +**Header affordance** (scope ruling). The past-day header is a plain text button with a +dotted underline on hover. The hover lock icon from the earlier revision is dropped, +because nobody asked for it. + +- [ ] **Step 1: Table component TS** + +In `time-plannings-table.component.ts`, extend 9A's util import: + +```ts +import {dayKey, formatReconciledProvenance, ReconcilePreview} from '../day-lock.util'; +``` + +Add these members. Do **not** add Task 12's `stopRowClick`: + +```ts + /** The bulk preview from the container (spec §8.3). Null when none is on screen. */ + @Input() reconcilePreview: ReconcilePreview | null = null; + /** Task 12: the ticked rows as site ids, emitted when the user ticks or unticks. */ + @Output() selectionChanged: EventEmitter = new EventEmitter(); + /** The grid dropped a non-empty selection by itself (mtx-grid gotcha 2). */ + @Output() selectionReset: EventEmitter = new EventEmitter(); + /** A past day header was clicked: preview a reconcile through that day. Commits nothing. */ + @Output() reconcileDateRequested: EventEmitter = new EventEmitter(); + + @ViewChild('reconcileDayHeaderTemplate', {static: true}) reconcileDayHeaderTemplate!: TemplateRef; + + /** + * The mtx-grid [headerTemplate] map. Only past day columns get the clickable header + * (I2). Columns missing from the map keep mtx-grid's default header. + */ + dayHeaderTemplates: {[field: string]: TemplateRef} = {}; + private columnDates: {[field: string]: Date} = {}; + private selectionActive = false; + + onRowSelected(rows: any[]): void { + const siteIds = (rows ?? []).map(r => r.siteId); + this.selectionActive = siteIds.length > 0; + this.selectionChanged.emit(siteIds); + } + + /** + * mtx-grid gotcha 2: the grid rebuilds its SelectionModel empty in ngOnChanges on + * ANY input change ([data], [columns], [headerTemplate]) and emits nothing. This + * reports that, and only when rows were ticked, as a RESET, not as an empty + * selection. An empty selection would mean "everyone visible" and would widen a + * previewed scope. The container cancels the preview instead. + */ + private resetSelection(): void { + if (this.selectionActive) { + this.selectionActive = false; + this.selectionReset.emit(); + } + } + + onDayHeaderClick(field: string): void { + const date = this.columnDates[field]; + if (date) { + this.reconcileDateRequested.emit(new Date(date)); + } + } + + /** A cell that WILL lock if the preview is committed. Cells already locked are left alone. */ + isPreviewLocked(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + const day = dayKey(row?.planningPrDayModels?.[field]?.date); + if (!landing || !day) { + return false; + } + const existing = this.reconcilePreview.existingBySiteId[row.siteId]; + return day <= landing && (!existing || day > existing); + } + + /** The cell the mark will land on: the new boundary. */ + isPreviewBoundary(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + return !!landing && dayKey(row?.planningPrDayModels?.[field]?.date) === landing; + } + + /** Already reconciled at or past the target: shown as skipped (§8.3). */ + isPreviewSkipped(row: any): boolean { + return this.reconcilePreview?.outcomeBySiteId[row?.siteId] === 'skip'; + } +``` + +The final `ngOnChanges` merges the existing body, 9A's legend line and gotcha 2: + +```ts + ngOnChanges(changes: SimpleChanges): void { + if (changes.dateFrom || changes.dateTo) { + if (changes.dateFrom !== undefined) { + this.dateFrom = changes.dateFrom.currentValue; + } + if (changes.dateTo !== undefined) { + this.dateTo = changes.dateTo.currentValue; + this.updateTableHeaders(); + } + } + if (changes.timePlannings && this.waitingForFreshData && this.pendingHighlight) { + // Fresh data has arrived after highlight was requested — now we can scroll + this.waitingForFreshData = false; + this.highlightApplied = false; + } + if (changes.timePlannings) { + this.hasLockedDayInView = this.computeHasLockedDayInView(); + this.resetSelection(); + } + } +``` + +The final `updateTableHeaders()` replaces :138-175: + +```ts + private updateTableHeaders(): void { + this.tableHeaders = []; + this.dayHeaderTemplates = {}; + this.columnDates = {}; + this.cdr.detectChanges(); + const startDate = new Date(this.dateFrom); + const endDate = new Date(this.dateTo); + const today = new Date(); + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); + const tempEndDate = new Date(endDate); + tempEndDate.setHours(0, 0, 0, 0); + const diff = (tempEndDate.getTime() - startDate.getTime()) / (1000 * 3600 * 24); + let daysCount = Math.floor(diff) +1; + let todayTranslated = this.translateService.stream('Today'); + const headerTemplates: {[field: string]: TemplateRef} = {}; + + this.tableHeaders = [ + { + cellTemplate: this.firstColumnTemplate, + header: this.translateService.stream('Name'), + pinned: 'left', + field: 'siteName', + sortable: true, + }, + ...Array.from({length: daysCount}).map((_, index) => { + const currentDate = new Date(startDate); + currentDate.setDate(startDate.getDate() + index); + const field = index.toString(); + this.columnDates[field] = currentDate; + // Only past days can be reconciled (I2), so only their headers become + // buttons. A past header is always a plain string: the Observable header is + // today's, and today never gets this template. + if (currentDate < todayMidnight) { + headerTemplates[field] = this.reconcileDayHeaderTemplate; + } + const isToday = currentDate.toDateString() === today.toDateString(); + const formattedDate = isToday + ? todayTranslated + : this.datePipe.transform(currentDate, 'E dd/MM', undefined, this.currentLocale) || ''; + return { + cellTemplate: this.dayColumnTemplate, + header: formattedDate, + field, + sortable: false, + class: (row: any) => this.getCellClass(row, field), + }; + }), + ]; + this.dayHeaderTemplates = headerTemplates; + // New [columns] and [headerTemplate] make mtx-grid drop its selection silently. + this.resetSelection(); + this.cdr.detectChanges(); + } +``` + +- [ ] **Step 2: Table template** + +**(a)** The final `` tag, replacing :19-30: + +```html + + + +``` + +**(b)** The final opening tag of `.plan-container` in `#dayColumnTemplate`, replacing +:238 and Task 12's version of it: + +```html + +
+``` + +**(c)** In **both** branches of `#firstColumnTemplate`, directly after +`{{ row[col.field] }}` (:63 and :162): + +```html + {{ 'reconcileRowSkipped' | translate }} +``` + +**(d)** A new template, after the closing `` of `#dayColumnTemplate` +(:434): + +```html + + + + +``` + +- [ ] **Step 3: Container TS** + +In `time-plannings-container.component.ts`, change the imports to: + +```ts +import {startOfWeek, endOfWeek, format, startOfDay, subDays} from 'date-fns'; +import {ToastrService} from 'ngx-toastr'; +import {TranslateService} from '@ngx-translate/core'; +import {ReconcileThroughResultModel} from '../../../models'; +import {buildReconcilePreview, ReconcilePreview} from '../day-lock.util'; +``` + +Add the injections beside the existing ones: + +```ts + private toastrService = inject(ToastrService); + private translateService = inject(TranslateService); +``` + +Add the fields. They replace Task 12's `selectedSiteIds`, `reconcileThroughDate` and +`maxReconcileDate`. Task 12's getter used `toISOString()`, which is UTC, so it gave a +max one day early between 00:00 and 02:00 Danish time. + +```ts + /** Ticked rows (site ids). Empty means every visible worker. */ + selectedSiteIds: number[] = []; + /** The bulk target, always a day on screen. See refreshReconcileBounds. */ + reconcileThroughDate: Date | null = null; + reconcilePreview: ReconcilePreview | null = null; + reconcileInFlight = false; + reconcileMinDate: Date | null = null; + reconcileMaxDate: Date | null = null; +``` + +The final `getPlannings()` replaces :142-152: + +```ts + getPlannings() { + this.buildTimePlanningsRequest(); + this.getTimePlannings$ = this.planningsService + .getPlannings(this.timePlanningsRequest) + .subscribe((data) => { + if (data && data.success) { + this.applyPlannings(data.model); + } + this.startPageTourOnce(); + }); + } +``` + +In `onShowResignedSitesChanged` (:298-300), replace +`this.timePlannings = planningsResult.model;` with: + +```ts + this.applyPlannings(planningsResult.model); +``` + +Add: + +```ts + /** + * Every path that replaces the rows comes through here: a day save, the + * assigned-site dialog, Reload, a filter, the resigned toggle. The grid drops the + * ticked rows on new data (mtx-grid gotcha 2). + * - If rows were ticked, the preview is CANCELLED, never rebuilt. A rebuild would + * fall back to "every visible worker" and silently widen the scope from the + * ticked rows to all of them. + * - If nothing was ticked, the scope was already "everyone visible", so the preview + * is redrawn on the new rows. + * Both happen before change detection, so the scope bar never changes after it + * has been checked. + */ + private applyPlannings(model: TimePlanningModel[]): void { + const hadSelection = this.selectedSiteIds.length > 0; + this.timePlannings = model; + this.selectedSiteIds = []; + this.refreshReconcileBounds(); + if (hadSelection) { + this.cancelReconcilePreview(); + } else { + this.rebuildReconcilePreview(); + } + } + + /** + * The bulk target must be on screen, because the preview can only draw what is on + * screen (§8.3). I2 caps it at yesterday. A null max means nothing in view can be + * reconciled. + */ + private refreshReconcileBounds(): void { + const from = startOfDay(this.dateFrom); + const yesterday = startOfDay(subDays(new Date(), 1)); + const lastVisible = startOfDay(this.dateTo); + const max = lastVisible < yesterday ? lastVisible : yesterday; + this.reconcileMinDate = from; + this.reconcileMaxDate = max < from ? null : max; + } + + get reconcileTargetLabel(): string { + return this.reconcileThroughDate ? format(this.reconcileThroughDate, 'dd.MM.yyyy') : ''; + } + + /** + * The user ticked or unticked a row. Unticking the last one returns the scope to + * "every visible worker", as the spec defines, and the scope bar's count changes + * in plain sight. + */ + onSelectionChanged(siteIds: number[]): void { + this.selectedSiteIds = siteIds; + this.rebuildReconcilePreview(); + } + + /** The grid dropped the ticked rows by itself (new columns). Cancel rather than widen. */ + onSelectionReset(): void { + if (this.selectedSiteIds.length === 0) { + return; // applyPlannings has already handled a reload + } + this.selectedSiteIds = []; + this.cancelReconcilePreview(); + } + + /** From the toolbar field or a day-column header. Starts the preview and commits nothing. */ + onReconcileDateChanged(date: Date | null): void { + this.reconcileThroughDate = date ? startOfDay(date) : null; + this.rebuildReconcilePreview(); + } + + cancelReconcilePreview(): void { + this.reconcileThroughDate = null; + this.reconcilePreview = null; + } + + private rebuildReconcilePreview(): void { + const target = this.reconcileThroughDate; + const onScreen = !!target && !!this.reconcileMinDate && !!this.reconcileMaxDate + && target >= this.reconcileMinDate && target <= this.reconcileMaxDate; + if (!onScreen) { + // Also covers navigating away from the target's week: the preview leaves with it. + this.cancelReconcilePreview(); + return; + } + // No selection means every worker currently visible under the active filters. + const scope = this.selectedSiteIds.length + ? this.selectedSiteIds + : this.timePlannings.map(x => x.siteId); + this.reconcilePreview = buildReconcilePreview(this.timePlannings, scope, format(target, 'yyyy-MM-dd')); + } + + confirmReconcileThrough(): void { + const preview = this.reconcilePreview; + if (!preview || this.reconcileInFlight || preview.willReconcileCount === 0) { + return; + } + this.reconcileInFlight = true; + // Exactly the rows the preview drew. Skipped rows go too, so that the toast reports + // what the server decided rather than what the client predicted. + this.planningsService.reconcileThrough(preview.target, preview.siteIds).subscribe({ + next: result => { + this.reconcileInFlight = false; + if (result && result.success && result.model) { + this.reportReconcileThrough(result.model); + this.cancelReconcilePreview(); + this.getPlannings(); + } + }, + error: () => { + this.reconcileInFlight = false; + }, + }); + } + + /** Applied and skipped counts, from the per-site result (Task 4, 8A). */ + private reportReconcileThrough(model: ReconcileThroughResultModel): void { + const skipped = model.skippedAlreadyFurtherForward.length + model.skippedNoRegistration.length; + let message = this.translateService.instant('reconcileThroughResult', {applied: model.applied, skipped}); + if (model.alreadyReconciledSiteIds.length) { + message += ' · ' + this.translateService.instant('reconcileThroughUnchanged', + {count: model.alreadyReconciledSiteIds.length}); + } + if (model.applied > 0) { + this.toastrService.success(message); + } else { + this.toastrService.warning(message); + } + } +``` + +- [ ] **Step 4: Container template** + +**(a)** The toolbar field goes after the `#workingHoursReload` button (:100-108). It +replaces Task 12 Step 2's `` and its `#reconcileThrough` button. The +field previews directly, so it needs no button: + +```html + +
+ + {{ 'Reconcile through' | translate }} + + + + +
+``` + +**(b)** The scope bar goes directly after `` (:124): + +```html + +
+ lock +
+ {{ 'reconcileScopeSummary' | translate: {date: reconcileTargetLabel, count: preview.willReconcileCount} }} + {{ 'reconcileScopeSkipped' | translate: {count: preview.skipCount} }} +
+ + +
+``` + +**(c)** The final table tag, replacing :136-144: + +```html + +``` + +- [ ] **Step 5: Container unit spec** + +The container now injects `ToastrService`, which the spec's TestBed does not provide, +so every test in the file would fail with `NullInjectorError`. In +`time-plannings-container.component.spec.ts`: + +Add the imports: + +```ts +import { ToastrService } from 'ngx-toastr'; +import { addDays, endOfWeek, format, startOfWeek, subDays } from 'date-fns'; +``` + +Add `reconcileThrough` to the plannings mock: + +```ts + mockPlanningsService = { + getPlannings: jest.fn(), + updatePlanning: jest.fn(), + reconcileThrough: jest.fn(), + } as any; +``` + +Add the provider: + +```ts + { provide: Store, useValue: mockStore }, + { provide: ToastrService, useValue: { success: jest.fn(), warning: jest.fn() } }, +``` + +Add these blocks at the end of the outer `describe`: + +```ts + describe('Bulk reconcile preview', () => { + const lastWeekStart = startOfWeek(subDays(new Date(), 7), { weekStartsOn: 1 }); + const lastWeekEnd = endOfWeek(lastWeekStart, { weekStartsOn: 1 }); + const rowFor = (siteId: number) => ({ + siteId, + lockedThrough: null, + planningPrDayModels: Array.from({ length: 7 }, (_, i) => ({ + id: siteId * 10 + i + 1, + date: `${format(addDays(lastWeekStart, i), 'yyyy-MM-dd')}T00:00:00`, + })), + }) as any; + + beforeEach(() => { + component.dateFrom = lastWeekStart; + component.dateTo = lastWeekEnd; + mockPlanningsService.getPlannings.mockReturnValue( + of({ success: true, model: [rowFor(1), rowFor(2)] }) as any); + component.getPlannings(); + }); + + it('previews every visible worker when no row is ticked', () => { + component.onReconcileDateChanged(lastWeekStart); + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + }); + + it('drops a target outside the days on screen', () => { + component.onReconcileDateChanged(subDays(lastWeekStart, 1)); + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileThroughDate).toBeNull(); + }); + + it('cancels, never widens, the preview when a reload drops the ticked rows', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(lastWeekStart); + expect(component.reconcilePreview?.siteIds).toEqual([1]); + + component.getPlannings(); + + expect(component.selectedSiteIds).toEqual([]); + expect(component.reconcilePreview).toBeNull(); + }); + + it('cancels the preview when the grid drops the ticked rows by itself', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(lastWeekStart); + + component.onSelectionReset(); + + expect(component.reconcilePreview).toBeNull(); + }); + }); + + describe('Reconcile through', () => { + it('sends the previewed rows and toasts the counts the server decided', () => { + const toastr = TestBed.inject(ToastrService) as any; + mockPlanningsService.reconcileThrough.mockReturnValue(of({ + success: true, + model: { + landedOnBySiteId: { 1: '2026-09-07T00:00:00' }, + applied: 1, + skippedAlreadyFurtherForward: [2], + skippedNoRegistration: [], + alreadyReconciledSiteIds: [], + }, + }) as any); + component.reconcilePreview = { + target: '2026-09-07', + siteIds: [1, 2], + landingBySiteId: { 1: '2026-09-07' }, + existingBySiteId: { 1: null, 2: '2026-09-09' }, + outcomeBySiteId: { 1: 'lock', 2: 'skip' }, + willReconcileCount: 1, + skipCount: 1, + }; + + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).toHaveBeenCalledWith('2026-09-07', [1, 2]); + // TranslateModule.forRoot() has no catalogue loaded, so instant() echoes the key. + expect(toastr.success).toHaveBeenCalledWith('reconcileThroughResult'); + expect(component.reconcilePreview).toBeNull(); + }); + }); +``` + +- [ ] **Step 6: Playwright, preview, skip and counts** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, closeDayWithoutChange, countPuts, expectSuccess, lastWeekMonday, openDashboardLastWeek, + openDay, RECONCILE_THROUGH_PATH, reconcileDay, rowCheckbox, rowOf, tdOf, unlockDay, + waitForIndex, waitForPut, waitForSpinner, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.3. The workers are picked from grid rows 7 (A), 8 (B) and 10 (outsider) at + * the start and are found by name after that. B is reconciled further forward (day 5) + * than the bulk target (day 3), so it must be shown as skipped and left where it is. + */ +test.describe('Reconciled day lock: bulk reconcile', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('a header click previews per worker, skips rows already further, and toasts counts', async ({ page }) => { + const a = await workerAtRow(page, 7); + const b = await workerAtRow(page, 8); + const outsider = await workerAtRow(page, 10); + const throughs = countPuts(page, RECONCILE_THROUGH_PATH); + + await reconcileDay(page, b, 5); + + await rowCheckbox(page, a).check(); + await rowCheckbox(page, b).check(); + + // mtx-grid gotcha 1: opening a day must not clear the batch selection. + await openDay(page, a, 0); + await closeDayWithoutChange(page); + await expect(rowCheckbox(page, a)).toBeChecked(); + await expect(rowCheckbox(page, b)).toBeChecked(); + + // Preview: the region is drawn in place, and nothing is written. + await page.locator('#dayHeader3').click(); + for (const day of [0, 1, 2, 3]) { + await expect(cellOf(page, a, day)).toHaveClass(/tp-preview-lock/); + } + await expect(cellOf(page, a, 3)).toHaveClass(/tp-preview-boundary/); + await expect(cellOf(page, a, 4)).not.toHaveClass(/tp-preview-lock/); + await expect(rowOf(page, b).locator('.tp-preview-skip-label')).toBeVisible(); + await expect(cellOf(page, b, 3)).toHaveClass(/tp-preview-skip/); + await expect(cellOf(page, b, 3)).not.toHaveClass(/tp-preview-lock/); + // The selection is the scope: an unticked worker is not previewed. + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + const summary = page.locator('#reconcileScopeSummary'); + await expect(summary).toHaveText(/^Afstem til og med \d{2}\.\d{2}\.\d{4} · Medarbejdere: 1$/); + await expect(page.locator('#reconcileScopeSkipped')).toContainText('Springes over: 1'); + expect(throughs.count).toBe(0); + + // Cancel drops the preview and still writes nothing. + await page.locator('#reconcileScopeCancel').click(); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + expect(throughs.count).toBe(0); + + // Commit. + await page.locator('#dayHeader3').click(); + const [, dd, mm, yyyy] = /(\d{2})\.(\d{2})\.(\d{4})/.exec(await summary.innerText())!; + const put = waitForPut(page, RECONCILE_THROUGH_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#reconcileScopeConfirm').click(); + const response = await put; + const body = await expectSuccess(response); + const sent = response.request().postDataJSON(); + expect(sent.date, 'the date sent is the date the scope bar named').toBe(`${yyyy}-${mm}-${dd}`); + expect(sent.siteIds).toHaveLength(2); + expect(body.model.applied).toBe(1); + expect(body.model.skippedAlreadyFurtherForward).toHaveLength(1); + await expect(page.locator('#toast-container')).toContainText('Afstemt: 1 · Sprunget over: 1'); + await reload; + await waitForSpinner(page); + + // The staircase: A at day 3, B untouched at day 5; the selection and the preview are gone. + await expect(tdOf(page, a, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, a, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, a, 4)).not.toHaveClass(/locked-background|reconciled-background/); + await expect(tdOf(page, b, 5)).toHaveClass(/reconciled-background/); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(rowCheckbox(page, a)).not.toBeChecked(); + + // Cleanup. + await unlockDay(page, a, 3); + await unlockDay(page, b, 5); + }); + + test('a reload while ticked rows are previewed cancels the preview instead of widening it', async ({ page }) => { + const a = await workerAtRow(page, 7); + const outsider = await workerAtRow(page, 10); + + await rowCheckbox(page, a).check(); + await page.locator('#dayHeader3').click(); + await expect(page.locator('#reconcileScopeSummary')).toContainText('Medarbejdere: 1'); + + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#workingHoursReload').click(); + await reload; + await waitForSpinner(page); + + // Silently becoming "every visible worker" would put the outsider in scope. Instead the preview is gone. + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 7: Commit** (replaces Task 12 Step 4's directory add) + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts +git commit -m "feat(lock): preview a bulk reconcile in place and report per-site counts" +``` + +If Task 12's own commit has not been made yet, it stages the same four component files +by name. It does not stage the spec or the Playwright file. + +--- + +## Task 13A: Strings in every locale, and the help-wiring contract + +**Follows:** Task 13. +**Supersedes:** +- Task 13 Step 6, the directory `git add`. + +Otherwise this task only adds: keys after Task 13 Step 5's block, and Task 13's five +keys in the locale files Task 13 did not touch. + +**Files:** +- Modify: `.../time-planning-pn/i18n/da.ts` and `.../i18n/enUS.ts` +- Modify: the 24 other locale files in `.../time-planning-pn/i18n/`, through the script in Step 3 +- Modify: `.../time-planning-pn/help/help-wiring.spec.ts` + +**Interfaces:** +- Consumes: the keys used in 9A, 11A, 11B and 12A. +- Produces: every key, present in all 26 locale files. + +**Why all locales.** `help-wiring.spec.ts` freezes the set of literal template +translate keys. Its comment gives the contract: a new key "forces a deliberate update +of all 25 shared locale files". The directory actually holds 26, next to +`translates.ts`. +- Identifier keys such as `reconcileScopeSummary` would render as raw keys in German, + Norwegian and every other locale. +- The 24 other files therefore get the English values. +- `UNLOCK` is `UNLOCK` in those files, so the typed-word check still works in every + language. + +- [ ] **Step 1: Danish** + +In `i18n/da.ts`, directly after Task 13 Step 5's five keys and before `};`: + +```ts + // Reconciled day lock: grid, dialog footer and bulk scope (Tasks 9A-12A). + Reconcile: 'Afstem', + lockedTooltip: 'Låst · ligger før en afstemt dag', + reconciledLegend: 'Afstemt · dagens tal er endelige', + reconciledProvenance: 'Afstemt {{date}} kl. {{time}}', + reconcileHeaderTooltip: 'Afstem til og med denne dag', + reconcileRowSkipped: 'Springes over · allerede afstemt længere frem', + reconcileScopeSummary: 'Afstem til og med {{date}} · Medarbejdere: {{count}}', + reconcileScopeSkipped: 'Springes over: {{count}} (allerede afstemt længere frem)', + reconcileThroughResult: 'Afstemt: {{applied}} · Sprunget over: {{skipped}}', + reconcileThroughUnchanged: 'Uændret: {{count}}', + reconcileDayConfirm: 'Afstem {{worker}} {{date}}? Dagen og alle dage før den bliver låst.', + reconcileNeedsSave: 'Gem ændringerne, før dagen afstemmes', + unlockFreeFirst: 'Låst, fordi {{date}} er afstemt. Lås {{date}} op først.', + unlockTypeWordPrompt: 'Afstemningen fjernes, og dagen kan redigeres igen. Skriv {{word}} for at bekræfte.', + UNLOCK: 'LÅS OP', +``` + +- [ ] **Step 2: English** + +In `i18n/enUS.ts`, directly after Task 13 Step 5's five keys and before `};`: + +```ts + // Reconciled day lock: grid, dialog footer and bulk scope (Tasks 9A-12A). + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', +``` + +No string mentions who may do what. Each one says what the day is, or what is about to +happen to it. + +- [ ] **Step 3: The other 24 locales, English until translated** + +Every locale file ends with a property that has a trailing comma, then `};`. This was +checked for all 26. The script inserts one block before that final `};` and is +idempotent: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +node - <<'EOF' +const fs = require('fs'); +const path = require('path'); +const dir = 'eform-client/src/app/plugins/modules/time-planning-pn/i18n'; +const block = ` // Reconciled day lock (Tasks 11-13A). English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', +`; +const skip = new Set(['da.ts', 'enUS.ts', 'translates.ts']); +let changed = 0; +for (const name of fs.readdirSync(dir).sort()) { + if (!name.endsWith('.ts') || skip.has(name)) continue; + const file = path.join(dir, name); + const src = fs.readFileSync(file, 'utf8'); + if (src.includes('reconciledProvenance:')) continue; + const at = src.lastIndexOf('};'); + if (at < 0) throw new Error(`${name}: no closing '};'`); + fs.writeFileSync(file, src.slice(0, at) + block + src.slice(at)); + changed++; +} +console.log(`updated ${changed} locale files`); +EOF +``` + +Expected output: `updated 24 locale files`. + +- [ ] **Step 4: Update the help-wiring contract** + +In `help/help-wiring.spec.ts`, replace `TEMPLATE_TRANSLATE_KEYS` (:44-53): + +```ts +const TEMPLATE_TRANSLATE_KEYS = [ + 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range', + 'Download Excel', 'Export to payroll', 'Flex', 'Flex balance at start of day', + 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'Needs update!', 'NettoHours', + 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours', + 'Planned working hours', 'Reload table', 'Reset pause to recorded', 'Save', + 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop', + 'Tags', 'Total breaktime', 'Total working hours', 'Use 1-minute intervals', + 'View GPS Location', 'View history', 'View Snapshot', 'Worker', 'Worktime start', + 'Worktime stop', + // Reconciled day lock (Tasks 11-12A). Every one is in all 26 locale files (Task 13A). + 'Reconcile', 'Reconcile day', 'Reconcile through', 'Unlock', + 'lockedTooltip', 'reconciledLegend', 'reconcileHeaderTooltip', 'reconcileRowSkipped', + 'reconcileScopeSummary', 'reconcileScopeSkipped', 'reconcileDayConfirm', 'reconcileNeedsSave', + 'unlockFreeFirst', 'unlockTypeWordPrompt', +]; +``` + +In the test `places an inline hint only where something conditional has just +happened`, replace the expected list (:147-149) to add the two banners from 11A Step 1: + +```ts + expect(hintIds.sort()).toEqual([ + 'dayCell.futureDisabled', 'dayCell.lockedByReconciled', 'dayCell.planHoursLimit', + 'dayCell.reconciled', 'grid.noWorkers', + ]); +``` + +- [ ] **Step 5: Verify the contract by hand** + +The spec itself runs only in CI, so the same checks are done with grep. + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +T="components/plannings/time-plannings-container/time-plannings-container.component.html +components/plannings/time-plannings-table/time-plannings-table.component.html +components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html" +``` + +**(a)** The literal template keys. The output must equal the new list exactly: + +```bash +grep -ohE "'[^']+'\s*\|\s*translate" $T | sed -E "s/^'([^']+)'.*/\1/" | sort -u +``` + +**(b)** No placeholder id in any template, comments included. The wiring spec reads +comments as markup (fact 9). Expect no output: + +```bash +grep -n 'helpId="…"\|data-tp-help="…"' $T +``` + +**(c)** Every literal help id or anchor in the templates has the `section.name` shape. +This catches any other placeholder written in a comment. Expect no output: + +```bash +grep -ohE '(helpId|data-tp-help)="[^"]*"' $T | grep -vE '="[a-zA-Z]+\.[a-zA-Z]+"' +``` + +**(d)** Every new key is in every locale file. Expect no output: + +```bash +for k in reconciledProvenance reconcileScopeSummary unlockTypeWordPrompt UNLOCK "'Reconcile day'" "'Reconcile through'"; do + for f in i18n/*.ts; do + [ "$(basename "$f")" = translates.ts ] && continue + grep -q "$k" "$f" || echo "MISSING $k in $f" + done +done +``` + +- [ ] **Step 6: Task 13's own commit, staged by name** (replaces Task 13 Step 6) + +Run this only if Task 13's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +P=eform-client/src/app/plugins/modules/time-planning-pn +git add $P/help/help.model.ts \ + $P/help/planning-help.registry.ts \ + $P/help/i18n/da.ts \ + $P/help/i18n/enUS.ts \ + $P/i18n/da.ts \ + $P/i18n/enUS.ts +git commit -m "feat(lock): add help entries and translations for the day lock" +``` + +- [ ] **Step 7: Commit 13A, every file by name** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +I=eform-client/src/app/plugins/modules/time-planning-pn/i18n +git add $I/bgBG.ts $I/csCZ.ts $I/da.ts $I/deDE.ts $I/elGR.ts $I/enUS.ts \ + $I/esES.ts $I/etET.ts $I/fiFI.ts $I/frFR.ts $I/hrHR.ts $I/huHU.ts \ + $I/isIS.ts $I/itIT.ts $I/ltLT.ts $I/lvLV.ts $I/nlNL.ts $I/noNO.ts \ + $I/plPL.ts $I/ptBR.ts $I/ptPT.ts $I/roRO.ts $I/skSK.ts $I/slSL.ts \ + $I/svSE.ts $I/ukUA.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts +git status --short # expect exactly these 27 files, and translates.ts untouched +git commit -m "feat(lock): translate the day-lock strings into every locale" +``` + +--- + +## Task 14A: Shard `s` bootstrap and Task 14's spec on the shared helpers + +**Follows:** Task 14. +**Supersedes:** +- **Task 14 Step 1:** the contents of `s/reconcile-day-lock.spec.ts`. +- **Task 14 Step 3:** the directory `git add`. + +Task 14 Step 2 (the matrix entry `s`) stands. + +**Files:** +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts` (copied from `r/`) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts` (copied from `r/`) +- Modify: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` + +**Interfaces:** +- Consumes: `s/reconcile-helpers.ts` (9A) and every UI id from 9A-12A. +- Produces: a shard that loads the plugin before any spec runs. + +**Three defects in Task 14 as written.** +1. **The plugin never loads in shard `s`.** Lanes `q` and `r` run on `a`'s seed with no + seed of their own, and they first run `activate-plugin.spec.ts` and + `assert-true.spec.ts`. Alphabetical order and `workers: 1` put those two first. + Setting `EformPlugins.Status = 1` in the seed does not load the plugin. Without the + bootstrap, the first `/plannings/index` wait in every `s` spec times out. +2. **The spec uses the current week.** On a Monday, `#cell3_1` (Tuesday) is in the + future; on a Tuesday it is today. Either way I2 hides `#reconcileButton`. +3. **The spec addresses rows by position and uses the old flows.** Its rows are + `#cell3_N` ids, and it clicks reconcile and unlock once each. After 11A and 11B, + reconcile takes two clicks and keeps the dialog open, and unlock takes a word. + +- [ ] **Step 1: The bootstrap** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/playwright/e2e/plugins/time-planning-pn +cp r/activate-plugin.spec.ts s/activate-plugin.spec.ts +cp r/assert-true.spec.ts s/assert-true.spec.ts +``` + +- [ ] **Step 2: Rewrite Task 14's spec on the helpers** + +`s/reconcile-day-lock.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayWithoutChange, openDashboardLastWeek, openDay, reconcileDay, tdOf, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** + * The end-to-end lock: reconcile, cascade, read-only, unlock. The worker is grid + * row 3 at the start and is found by name after that. Last week, so both days are + * in the past (I2) whatever weekday CI runs on. + */ +test.describe('Reconciled day lock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('reconciling a day locks it and every earlier day for that worker', async ({ page }) => { + const worker = await workerAtRow(page, 3); + + await reconcileDay(page, worker, 1); + + // The boundary treatment, and the day before it locked but not reconciled. + await expect(tdOf(page, worker, 1)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 0)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 0)).not.toHaveClass(/reconciled-background/); + + // The locked day opens read-only, for the same worker, with no save and no unlock. + await openDay(page, worker, 0); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await closeDayWithoutChange(page); + + // The boundary day offers unlock, and using it frees both days. + await unlockDay(page, worker, 1); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 0)).not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 3: Task 14's own commit, staged by name** (replaces Task 14 Step 3) + +Run this only if Task 14's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts \ + .github/workflows/dotnet-core-pr.yml \ + .github/workflows/dotnet-core-master.yml +git commit -m "test(lock): end-to-end reconcile, cascade and unlock" +``` + +- [ ] **Step 4: Commit 14A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts +git commit -m "test(lock): bootstrap shard s and anchor the lock e2e by worker" +``` + +**Watching CI (Task 15).** +- **File order.** The `s` job runs `activate-plugin`, `assert-true`, + `reconcile-bulk-preview`, `reconcile-day-lock`, `reconcile-dialog-confirm`, + `reconcile-glyphs`, `reconcile-unlock-word`, in that order. +- **A failure can leave a worker locked.** Each later spec uses its own worker, so the + specs after it still run. +- **The canary.** The glyph spec's precondition, `#lockLegend` count 0, fails when an + earlier spec leaked a lock. A red there is a leak, not a glyph bug: fix the first + failure first. diff --git a/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md index e7898404..36225dd0 100644 --- a/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md @@ -2,7 +2,11 @@ **Parked 2026-09-13. This file is the entry point — read it before the spec or the plan.** -**State:** design and plan complete and reviewed. **No implementation code written.** +**State (updated 2026-09-15):** execution under way on `feat/reconciled-day-lock-backend` +(PR #1711). "THE OPEN DECISION" section below is settled: §8.1–8.4 are all in scope, with tasks in +the plan's Addendum B. Rulings taken during execution are summarised in spec §13. + +**Original state (2026-09-13):** design and plan complete and reviewed. No implementation code written. **Branch:** `docs/reconciled-day-lock-spec` — 3 commits, **never pushed, no PR**, working tree clean. diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index c69d5856..1196dd42 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -230,9 +230,14 @@ Gap-fill **stops at the boundary**: no row creation, no recomputation for `date <= lockedThrough`. A locked period is frozen exactly as it stands. Consequence, accepted deliberately: a day inside a locked range that never had -a registration stays empty in the grid rather than materialising a blank row. -That is the correct reading — nothing was registered that day — and it is what -makes "reconciled" mean byte-stable rather than merely read-only. +a registration gets no row. Nothing was registered that day, and not creating +one is what makes "reconciled" mean byte-stable rather than merely read-only. + +**Revised during implementation (ruling F17):** the dashboard grid reads days +*by position* (`field = index`), so a missing day would shift every later day +one column left. The read model therefore carries a **non-persisted +placeholder day** (`Id = 0`) for each locked missing date, keeping one entry +per date. Nothing is written to the database; the cell renders empty. ### 6.4 Timezone @@ -371,8 +376,9 @@ this plugin has never used. The host's backend-configuration task-list is the precedent and documents two gotchas that apply verbatim: 1. mtx-grid binds `(click)="_selectRow()"` on the ``; with `[rowSelectable]` - this **clears the batch selection** when a day cell is clicked. The day cell - must call `stopRowClick($event)`. + this **clears the batch selection** when a day cell is clicked. Use mtx-grid's + `[disableRowClickSelection]` (it still emits `rowClick`), which also covers + clicks on the Name column; a per-cell `stopRowClick($event)` would not. 2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` **without emitting** `rowSelectedChange`, so the component must re-emit an empty selection itself. @@ -473,3 +479,39 @@ None blocking. Two worth revisiting after the first release: - Whether `ReconciledBy` is wanted on the face of the record. It is currently recoverable from `PlanRegistrationVersion.UpdatedByUserId` (§3), and adding it would require the base-repo migration this design otherwise avoids. +- **Open (from implementation):** §8.1 gives locked cells a `not-allowed` + cursor, while §8.5 has them open a read-only dialog on click. Left as + written; worth a look in the browser. + +## 13. Revisions during implementation (2026-09-15) + +Rulings taken while executing the plan. Each is recorded, with its cost if +wrong, in the SDD ledger. They supersede the sections they name. + +- **§8.1–8.4 are all in scope** (user decision). Tasks for them were written + and reviewed before the frontend phase. +- **I3 and payroll (F10).** The interceptor permits a *payroll-flag-only* + write inside the lock (`TransferredToPayroll`/`TransferredToPayrollAt` plus + bookkeeping columns). Without this, exporting a reconciled period fails + midway: the file is built, some flags are set and the rest are not. This is + what §11.2's independence requires. Hours are never writable. +- **I3 is checked on the original and the current slot.** Changing a locked + row's `Date` or `SdkSitId` cannot move it out of the lock. +- **Unlock must clear both columns (I1 at the choke point).** Clearing + `Reconciled` while leaving `ReconciledAt` set is rejected. +- **Recalculation reverts, it does not just skip saves (F15).** The dashboard + recompute mutates *tracked* rows. Skipping only the save lets the next open + day's save flush a locked row. Locked days are reverted before any later + save, so the grid shows the stored, reconciled values. +- **Gap-fill placeholders (F17).** See §6.3. +- **Working-hours bulk save skips (F16).** That page posts every row in its + range, so §6.1's "localized failure" would make any range touching a + reconciled day unsaveable. The save skips locked rows, and the page shows + them read-only through its existing `IsLocked` flag. +- **More write paths to guard (F11, F12, F13). Ruled and briefed; not yet + implemented.** An audit found writes outside §5's list that reach locked + days: startup pause-id repair (unguarded, it would crash host startup), + Google Sheet pull, Excel import, the flex screen, absence approval and shift + handover, plus the service repo's sheet pull and flex catch-up. Task 5B + (plugin) and Task 7B (service repo) will make bulk re-syncs skip locked days + and give a user acting on specific days a message. From 928a69be388223323e217647d7f10036926c00da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 16:02:34 +0200 Subject: [PATCH 12/18] fix(lock): keep startup repair, sheet pull, import, flex, absence and handover inside the lock - The startup pause-id repair skips locked rows, runs on an interceptor-guarded context, and a DayLockedException can no longer take the host down. - Sheet pull, import and the flex follow-up skip locked rows before tracking them. - Flex corrections, absence approval and handover accept are refused up front with a message that says what the day is. - CorruptedPauseIdRepairTests now runs in CI (it was in no shard). Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../AbsenceRequestServiceTests.cs | 100 ++++++++++ .../ContentHandoverServiceTests.cs | 107 ++++++++++ .../CorruptedPauseIdRepairTests.cs | 74 +++++-- ...yrollExportRemovedPlanRegistrationTests.cs | 47 ++++- .../TimePlanningFlexServiceRemovedRowTests.cs | 184 ++++++++++++++++++ .../WorkingHoursImportRemovedRowTests.cs | 101 ++++++++-- .../EformTimePlanningPlugin.cs | 26 ++- .../Helpers/CorruptedPauseIdRepair.cs | 22 ++- .../Infrastructure/Helpers/DayLockHelper.cs | 33 ++++ .../Helpers/GoogleSheetHelper.cs | 16 ++ .../AbsenceRequestService.cs | 16 ++ .../ContentHandoverService.cs | 18 ++ .../TimePlanningFlexService.cs | 29 +++ .../TimePlanningPlanningService.cs | 4 +- .../TimePlanningWorkingHoursService.cs | 19 +- 17 files changed, 754 insertions(+), 46 deletions(-) diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 60789b59..bb53b746 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index ca6dc05f..969029ca 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs index 110525ee..027ee2d4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs @@ -195,6 +195,106 @@ public async Task ApproveAsync_UpdatesPlanRegistrations_AndSetsAbsenceFlags() Assert.That(pr2.OnVacation, Is.False); } + /// + /// Approving writes the request's status BEFORE the per-day loop. Without + /// the up-front check the status is saved as Approved, the locked day's + /// save is then refused, and the request is left Approved with only some + /// of its days flagged. + /// + /// The message says what the earliest locked requested day IS: a day with + /// no registration yet, or with a plain one, is locked by the boundary; + /// the reconciled boundary day itself is reconciled. + /// + [TestCase(2, "DayIsLockedByReconciledDay", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayHasNoRow")] + [TestCase(3, "DayIsLockedByReconciledDay", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayIsBelowTheBoundary")] + [TestCase(4, "DayIsReconciled", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayIsTheReconciledDay")] + public async Task ApproveAsync_Fails_AndPersistsNothing_WhenARequestedDayIsLocked( + int firstRequestedDayOfMarch, string expectedMessage) + { + const int sdkSitId = 11; + var firstRequestedDay = new DateTime(2024, 3, firstRequestedDayOfMarch); + var lockedDay = new DateTime(2024, 3, 3); + var boundaryDay = new DateTime(2024, 3, 4); + var openDay = new DateTime(2024, 3, 5); + + // Lock-safe order: the requested locked day exists BEFORE the later + // boundary day is reconciled. + var locked = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = lockedDay, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await locked.Create(TimePlanningPnDbContext); + var boundary = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = boundaryDay, + Reconciled = true, + ReconciledAt = new DateTime(2024, 3, 6, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await boundary.Create(TimePlanningPnDbContext); + var before = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().Where(pr => pr.SdkSitId == sdkSitId).ToListAsync(); + + // One day per date in the range, as CreateAsync builds it, always + // ending on the open day after the boundary. 2 March has no row. + var request = new AbsenceRequest + { + RequestedBySdkSitId = sdkSitId, + DateFrom = firstRequestedDay, + DateTo = openDay, + Status = AbsenceRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await request.Create(TimePlanningPnDbContext); + for (var date = firstRequestedDay; date <= openDay; date = date.AddDays(1)) + { + await new AbsenceRequestDay + { + AbsenceRequestId = request.Id, + Date = date, + MessageId = 2, // Vacation + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var result = await _absenceRequestService.ApproveAsync(request.Id, + new AbsenceRequestDecisionModel { ManagerSdkSitId = 2, DecisionComment = "Approved" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + // AsNoTracking: the database, not the tracked instance the service + // may have changed in memory. + var requestAfter = await TimePlanningPnDbContext.AbsenceRequests + .AsNoTracking().FirstAsync(ar => ar.Id == request.Id); + Assert.That(requestAfter.Status, Is.EqualTo(AbsenceRequestStatus.Pending)); + Assert.That(requestAfter.DecidedBySdkSitId, Is.Null); + + foreach (var id in new[] { locked.Id, boundary.Id }) + { + var after = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == id); + Assert.That(after.OnVacation, Is.False, "a locked day must not be flagged"); + Assert.That(after.MessageId, Is.Null); + Assert.That(after.Version, Is.EqualTo(before.Single(pr => pr.Id == id).Version)); + } + + var rowCount = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().CountAsync(pr => pr.SdkSitId == sdkSitId); + Assert.That(rowCount, Is.EqualTo(2), "no day of a refused request is written"); + } + [Test] public async Task RejectAsync_ChangesStatus_WithoutUpdatingPlanRegistrations() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index 9ce6dd7b..56b0edc0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -370,6 +370,113 @@ public async Task AcceptAsync_Fails_WhenTargetHasContent() Assert.That(result.Message, Is.EqualTo("TargetPlanRegistrationMustBeEmpty")); } + /// + /// The two rows belong to two workers, each with their own boundary, and + /// the receiver is persisted before the sender. Without the up-front check: + /// a locked SENDER leaves the shift copied onto the receiver and still on + /// the sender; a locked RECEIVER fails with the generic error. Either way + /// both rows and the request must stay exactly as they were. + /// + /// The message says what the blocking row IS, checked on whichever + /// worker's row blocks (the sender is the counterpart of the accepting + /// receiver): locked by a later reconciled day, or reconciled itself. + /// + [TestCase(true, false, "DayIsLockedByReconciledDay", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheSendersDayIsLocked")] + [TestCase(false, false, "DayIsLockedByReconciledDay", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheReceiversDayIsLocked")] + [TestCase(true, true, "DayIsReconciled", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheSendersDayIsReconciled")] + [TestCase(false, true, "DayIsReconciled", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheReceiversDayIsReconciled")] + public async Task AcceptAsync_Fails_AndWritesNothing_WhenEitherWorkersDayIsLocked( + bool lockSender, bool reconcileTheHandoverDay, string expectedMessage) + { + var date = new DateTime(2024, 4, 8); + + var sourcePR = new PlanRegistration + { + Date = date, + SdkSitId = 1, + PlanHours = 8, + PlanHoursInSeconds = 28800, + PlanText = "Important work", + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var targetPR = new PlanRegistration + { + Date = date, + SdkSitId = 2, + PlanHours = 0, + PlanHoursInSeconds = 0, + PlanText = null, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var lockedWorkersRow = lockSender ? sourcePR : targetPR; + if (reconcileTheHandoverDay) + { + // The handover day itself is the locked worker's boundary. + lockedWorkersRow.Reconciled = true; + lockedWorkersRow.ReconciledAt = new DateTime(2024, 4, 9, 9, 12, 0); + } + await sourcePR.Create(TimePlanningPnDbContext); + await targetPR.Create(TimePlanningPnDbContext); + + if (!reconcileTheHandoverDay) + { + // Lock-safe order: the handover day exists BEFORE a later day of + // the locked worker is reconciled, which locks the handover day too. + await new PlanRegistration + { + Date = date.AddDays(2), + SdkSitId = lockedWorkersRow.SdkSitId, + Reconciled = true, + ReconciledAt = new DateTime(2024, 4, 11, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var request = new PlanRegistrationContentHandoverRequest + { + FromSdkSitId = 1, + ToSdkSitId = 2, + Date = date, + FromPlanRegistrationId = sourcePR.Id, + ToPlanRegistrationId = targetPR.Id, + Status = HandoverRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await request.Create(TimePlanningPnDbContext); + + var result = await _contentHandoverService.AcceptAsync(request.Id, 2, + new ContentHandoverDecisionModel { DecisionComment = "Accepted" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + // AsNoTracking: the database, not the tracked instances the service + // shares with this test. + var sourceAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == sourcePR.Id); + var targetAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == targetPR.Id); + Assert.That(sourceAfter.PlanText, Is.EqualTo("Important work")); + Assert.That(sourceAfter.PlanHoursInSeconds, Is.EqualTo(28800)); + Assert.That(sourceAfter.Version, Is.EqualTo(1), "the sender's row must not be saved"); + Assert.That(targetAfter.PlanText, Is.Null); + Assert.That(targetAfter.PlanHoursInSeconds, Is.EqualTo(0)); + Assert.That(targetAfter.Version, Is.EqualTo(1), "the receiver's row must not be saved"); + + var requestAfter = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests + .AsNoTracking().FirstAsync(r => r.Id == request.Id); + Assert.That(requestAfter.Status, Is.EqualTo(HandoverRequestStatus.Pending)); + } + [Test] public async Task RejectAsync_ChangesStatus_WithoutMovingContent() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs index c01a3983..0c3cc5bd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs @@ -14,17 +14,9 @@ namespace TimePlanning.Pn.Test; [TestFixture] public class CorruptedPauseIdRepairTests : TestBaseSetup { - [SetUp] - public async Task SetUp() - { - await base.Setup(); - } - - [TearDown] - public new async Task TearDown() - { - await base.TearDown(); - } + // No [SetUp]/[TearDown] here: TestBaseSetup's own run for every test, and + // re-declaring them made NUnit run each twice (a second drop/migrate/seed + // and a leaked context per test). // ---- B0: characterize the timestamp-preferring netto writer ---------- @@ -69,14 +61,17 @@ public async Task Repair_FixesOnlyInWindow5MinCorruptedRows() var fiveMinSite = await SeedAssignedSite(ctx, siteId: 100, useOneMinute: false); var oneMinSite = await SeedAssignedSite(ctx, siteId: 200, useOneMinute: true); + // (a)-(c) sit on consecutive in-window days: PlanRegistration has a + // unique (SdkSitId, Date, WorkflowState) index, so one site cannot + // hold two active rows on the same day. // (a) corrupted in-window: 30m real pause, Pause1Id=145 (absolute 12:00 tick). var corrupted = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); // (b) correct in-window: 30m pause, Pause1Id=7 ((30/5)+1). - var correct = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, + var correct = await SeedRow(ctx, fiveMinSite.SiteId, inWindow.AddDays(1), pauseStart: 12, pauseStopMin: 30, pause1Id: 7, work: (8, 16)); // (c) off-by-one in-window: Pause1Id=6 (min/5, missing +1) -> must be left alone. - var offByOne = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, + var offByOne = await SeedRow(ctx, fiveMinSite.SiteId, inWindow.AddDays(2), pauseStart: 12, pauseStopMin: 30, pause1Id: 6, work: (8, 16)); // (d) corrupted but out of window (on/before the locked cutoff). var oldRow = await SeedRow(ctx, fiveMinSite.SiteId, locked, @@ -144,6 +139,55 @@ public async Task Repair_DoesNotWrite_WhenCorruptIdHasNoTimestamps() Assert.That((await Reload(ctx, row)).Pause1Id, Is.EqualTo(145)); // untouched } + // ---- day lock: frozen means frozen ------------------------------------- + + /// + /// The repair window is a fixed calendar cutoff, so it can reach days at + /// or below a worker's reconciled boundary. Those rows must be skipped + /// before anything mutates them. Without the skip the first locked row's + /// Update is refused by the interceptor, and Run throws: in production + /// that is host startup. + /// + [Test] + public async Task Repair_SkipsRowsAtOrBelowTheReconciledBoundary_AndRepairsTheRest() + { + var ctx = TimePlanningPnDbContext!; + var site = await SeedAssignedSite(ctx, siteId: 300, useOneMinute: false); + var windowStart = CorruptedPauseIdRepair.FirstUnlockedDate(DateTime.UtcNow.Date); + + // Lock-safe order: the row below the boundary is created BEFORE the + // boundary is reconciled, and the row above it after. All three carry + // the same repairable corruption (30m real pause, absolute tick 145). + // Near the payroll cutoff the boundary (W+1) is not in the past: it is + // in the future on the 21st and 22nd and is today on the 23rd. The lock + // itself does not check I2, so the test holds on every day. + var belowBoundary = await SeedRow(ctx, site.SiteId, windowStart, + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); + var boundary = await SeedRow(ctx, site.SiteId, windowStart.AddDays(1), + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16), + reconciled: true); + var aboveBoundary = await SeedRow(ctx, site.SiteId, windowStart.AddDays(2), + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); + + var belowBefore = await Reload(ctx, belowBoundary); + var boundaryBefore = await Reload(ctx, boundary); + + Assert.DoesNotThrowAsync(async () => await CorruptedPauseIdRepair.Run(ctx)); + + foreach (var (before, label) in new[] { (belowBefore, "below"), (boundaryBefore, "boundary") }) + { + var after = await Reload(ctx, before); + Assert.That(after.Pause1Id, Is.EqualTo(145), $"{label}: a locked row must not be repaired"); + Assert.That(after.NettoHoursInSeconds, Is.EqualTo(before.NettoHoursInSeconds), label); + Assert.That(after.Version, Is.EqualTo(before.Version), $"{label}: a locked row must not be saved"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt), label); + } + + var repaired = await Reload(ctx, aboveBoundary); + Assert.That(repaired.Pause1Id, Is.EqualTo(7), "the row above the boundary is still repaired"); + Assert.That(repaired.NettoHoursInSeconds, Is.EqualTo(27000)); + } + // ---- helpers ----------------------------------------------------------- private static async Task SeedAssignedSite( @@ -163,7 +207,7 @@ private static async Task SeedAssignedSite( private static async Task SeedRow( TimePlanningPnDbContext ctx, int sdkSitId, DateTime date, int pauseStart, int pauseStopMin, int pause1Id, (int Start, int Stop) work, - bool seedPauseTimestamps = true) + bool seedPauseTimestamps = true, bool reconciled = false) { var pr = new PlanRegistration { @@ -174,6 +218,8 @@ private static async Task SeedRow( Pause1StartedAt = seedPauseTimestamps ? date.AddHours(pauseStart) : (DateTime?)null, Pause1StoppedAt = seedPauseTimestamps ? date.AddHours(pauseStart).AddMinutes(pauseStopMin) : (DateTime?)null, Pause1Id = pause1Id, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, CreatedByUserId = 1, UpdatedByUserId = 1 }; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs index 853fbbda..1c21a814 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs @@ -50,12 +50,14 @@ public async Task SetUpTest() } private async Task SeedPlanRegistrationWithPayLine( - int sdkSitId, DateTime date, double hours, bool removed) + int sdkSitId, DateTime date, double hours, bool removed, bool reconciled = false) { var pr = new PlanRegistration { SdkSitId = sdkSitId, Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 2, 2, 9, 12, 0) : (DateTime?)null, CreatedByUserId = 1, UpdatedByUserId = 1 }; @@ -131,4 +133,47 @@ public async Task ExportPayroll_TreatsRemovedPlanRegistrationPayLineAsNoData() Assert.That(result.ErrorMessage, Is.EqualTo("NoPayrollDataForPeriod"), "A removed PlanRegistration must not be exported as payable hours"); } + + /// + /// Spec §11.2 (ruling F10): reconciling a period must not block exporting + /// it. Export flags every exported row, locked or not, so without the + /// interceptor's payroll-flag exemption the first locked row's Update is + /// refused and the export fails with the file already produced. + /// + [Test] + public async Task ExportPayroll_FlagsRowsInsideAReconciledPeriod() + { + var periodStart = new DateTime(2026, 1, 1); + var periodEnd = new DateTime(2026, 1, 31); + + var settings = new PayrollIntegrationSettings + { + PayrollSystem = 1, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await settings.Create(TimePlanningPnDbContext); + + // Lock-safe order: the day below the boundary is created BEFORE the + // boundary day is reconciled. + var belowBoundary = await SeedPlanRegistrationWithPayLine( + 400, new DateTime(2026, 1, 13), 7.0, removed: false); + var boundary = await SeedPlanRegistrationWithPayLine( + 400, new DateTime(2026, 1, 14), 8.0, removed: false, reconciled: true); + + var result = await _service.ExportPayroll(periodStart, periodEnd); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + foreach (var id in new[] { belowBoundary.Id, boundary.Id }) + { + var row = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == id); + Assert.That(row.TransferredToPayroll, Is.True, $"row {id} must be flagged as exported"); + Assert.That(row.TransferredToPayrollAt, Is.Not.Null); + } + + var reloadedBoundary = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloadedBoundary.Reconciled, Is.True, "exporting must not unlock the day"); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs index 1f6620fe..6846fd1b 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs @@ -15,6 +15,7 @@ using TimePlanning.Pn.Infrastructure.Models.Settings; using TimePlanning.Pn.Services.TimePlanningFlexService; using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; namespace TimePlanning.Pn.Test; @@ -117,4 +118,187 @@ public async Task UpdateCreate_DoesNotWriteIntoRemovedRow_CreatesFreshActiveRow( Assert.That(activeRows[0].CommentOffice, Is.EqualTo("NEW")); Assert.That(activeRows[0].PaiedOutFlex, Is.EqualTo(9)); } + + // ---- day lock ----------------------------------------------------------- + + private async Task SeedFlexRow( + int sdkSitId, DateTime date, string commentOffice, bool reconciled = false, int statusCaseId = 0) + { + var row = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = date, + CommentOffice = commentOffice, + PaiedOutFlex = 1, + SumFlexEnd = 10, + StatusCaseId = statusCaseId, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await row.Create(TimePlanningPnDbContext); + return row; + } + + private static TimePlanningFlexUpdateModel FlexEntry(int sdkSitId, DateTime date, string commentOffice) => + new() + { + Date = date, + Worker = new CommonDictionaryModel { Id = sdkSitId }, + CommentOffice = commentOffice, + CommentOfficeAll = commentOffice, + PaidOutFlex = 4, + SumFlexStart = 20 + }; + + /// + /// The office edits specific days, so a locked day in the batch refuses + /// the WHOLE batch with a message before anything is written. The open + /// entry is posted FIRST: without the guard it is saved, and then the + /// locked entry's save is refused, leaving the batch half applied and + /// answering with the generic error. + /// + /// The message says what the blocking day IS: a day below the boundary + /// is locked by it, while the reconciled boundary day is reconciled. + /// + [TestCase(false, "DayIsLockedByReconciledDay", + TestName = "UpdateCreate_RejectsTheWholeBatch_WhenAnEntryIsBelowTheBoundary")] + [TestCase(true, "DayIsReconciled", + TestName = "UpdateCreate_RejectsTheWholeBatch_WhenAnEntryIsTheReconciledDay")] + public async Task UpdateCreate_RejectsTheWholeBatch_WhenAnyEntryIsOnALockedDay( + bool postTheReconciledDay, string expectedMessage) + { + const int sdkSitId = 556; + var lockedDay = DateTime.Now.Date.AddDays(-4); + var boundaryDay = DateTime.Now.Date.AddDays(-3); + var openDay = DateTime.Now.Date.AddDays(-1); + + // Lock-safe order: the row below the boundary, then the boundary, then + // the row above it. + var locked = await SeedFlexRow(sdkSitId, lockedDay, "LOCKED-ORIG"); + var boundary = await SeedFlexRow(sdkSitId, boundaryDay, "BOUNDARY-ORIG", reconciled: true); + var open = await SeedFlexRow(sdkSitId, openDay, "OPEN-ORIG"); + // Snapshots, not the seeded instances: the service loads the same + // tracked objects, so their Version would move along with any write. + var before = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().Where(x => x.SdkSitId == sdkSitId).ToListAsync(); + + var result = await _service.UpdateCreate( + [ + FlexEntry(sdkSitId, openDay, "NEW-OPEN"), + FlexEntry(sdkSitId, postTheReconciledDay ? boundaryDay : lockedDay, "NEW-LOCKED") + ]); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + foreach (var (id, comment) in new[] + { + (locked.Id, "LOCKED-ORIG"), (boundary.Id, "BOUNDARY-ORIG"), (open.Id, "OPEN-ORIG") + }) + { + var after = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == id); + Assert.That(after.CommentOffice, Is.EqualTo(comment), "nothing in a refused batch is written"); + Assert.That(after.PaiedOutFlex, Is.EqualTo(1)); + Assert.That(after.Version, Is.EqualTo(before.Single(x => x.Id == id).Version)); + } + + var rowCount = await TimePlanningPnDbContext.PlanRegistrations.CountAsync(x => x.SdkSitId == sdkSitId); + Assert.That(rowCount, Is.EqualTo(3), "a refused batch creates no rows"); + } + + /// + /// A worker whose YESTERDAY is reconciled can still get today's flex entry: + /// the batch guard checks each posted day, not whether the worker has a + /// boundary at all. The follow-up loop then walks rows dated after + /// now minus two days, which includes that reconciled yesterday. The + /// loop's own skip is pinned by the next test. + /// + [Test] + public async Task UpdateCreate_AcceptsToday_WhenYesterdayIsReconciled_AndLeavesYesterdayAlone() + { + const int sdkSitId = 557; + var yesterday = DateTime.Now.Date.AddDays(-1); + var today = DateTime.Now.Date; + + await SeedSdkSite(sdkSitId, "FlexLockSite"); + + var reconciledYesterday = await SeedFlexRow( + sdkSitId, yesterday, "YESTERDAY-ORIG", reconciled: true, statusCaseId: 42); + var yesterdayBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + + var result = await _service.UpdateCreate([FlexEntry(sdkSitId, today, "NEW-TODAY")]); + + Assert.That(result.Success, Is.True, result.Message); + + var yesterdayAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + Assert.That(yesterdayAfter.CommentOffice, Is.EqualTo("YESTERDAY-ORIG")); + Assert.That(yesterdayAfter.Version, Is.EqualTo(yesterdayBefore.Version)); + Assert.That(yesterdayAfter.UpdatedAt, Is.EqualTo(yesterdayBefore.UpdatedAt)); + Assert.That(yesterdayAfter.Reconciled, Is.True); + + var todayRow = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .SingleAsync(x => x.SdkSitId == sdkSitId && x.Date == today + && x.WorkflowState != Constants.WorkflowStates.Removed); + Assert.That(todayRow.CommentOffice, Is.EqualTo("NEW-TODAY")); + } + + /// + /// Pins the follow-up loop's WhereOpen skip. That loop calls Update on + /// every row it loads without changing it, and PnBase.Update saves only + /// when the context has ANY pending change, bumping the row's Version and + /// UpdatedAt as it does. So an unrelated unsaved change on the shared + /// context is enough: without the skip, the reconciled yesterday is + /// loaded, its Update carries that save, the interceptor refuses the + /// bumped locked row, and UpdateCreate fails. An EMPTY batch keeps the + /// posted-day guard out of it. + /// + [Test] + public async Task UpdateCreate_FollowUpLoop_NeverLoadsAReconciledYesterday() + { + const int sdkSitId = 558; + var yesterday = DateTime.Now.Date.AddDays(-1); + + // What the loop needs to select and walk the row: StatusCaseId != 0, + // a date after now minus two days, and an SDK site with a language. + await SeedSdkSite(sdkSitId, "FlexFollowUpSite"); + var reconciledYesterday = await SeedFlexRow( + sdkSitId, yesterday, "YESTERDAY-ORIG", reconciled: true, statusCaseId: 42); + var yesterdayBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + + // An unrelated pending change, tracked but deliberately NOT saved. + var unrelated = new AssignedSiteEntity { SiteId = 900, CreatedByUserId = 1, UpdatedByUserId = 1 }; + await unrelated.Create(TimePlanningPnDbContext); + unrelated.UpdatedByUserId = 2; + + var result = await _service.UpdateCreate([]); + + Assert.That(result.Success, Is.True, result.Message); + + var yesterdayAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + Assert.That(yesterdayAfter.Version, Is.EqualTo(yesterdayBefore.Version)); + Assert.That(yesterdayAfter.UpdatedAt, Is.EqualTo(yesterdayBefore.UpdatedAt)); + } + + /// + /// An SDK site with a language: the follow-up loop resolves both for every + /// row it loads, as production has them. + /// + private async Task SeedSdkSite(int sdkSitId, string name) + { + var core = await GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + var language = await sdkDbContext.Languages.FirstAsync(); + await new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = name, MicrotingUid = sdkSitId, LanguageId = language.Id + }.Create(sdkDbContext); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs index 8f95c1a0..9e8a483b 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs @@ -108,17 +108,9 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() await active.Create(TimePlanningPnDbContext); var activeId = active.Id; - var xlsx = BuildWorkbook(siteName, dateStr, "8", "IMPORTED"); - var file = Substitute.For(); - file.CopyToAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - var target = ci.Arg(); - target.Write(xlsx, 0, xlsx.Length); - return Task.CompletedTask; - }); + var xlsx = BuildWorkbook(siteName, (dateStr, "8", "IMPORTED")); - var result = await _service.Import(file); + var result = await _service.Import(FormFile(xlsx)); // Post-fix: no crash, and the ACTIVE row is the import target. Assert.That(result.Success, Is.True, result.Message); @@ -140,6 +132,79 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() Assert.That(activeCount, Is.EqualTo(1), "Import must not create a duplicate active row"); } + /// + /// A bulk import skips a locked day instead of failing the whole file. + /// Without the skip the locked row is loaded, changed and saved, the + /// interceptor refuses it, and Import returns Success false with the later + /// rows never imported. + /// + /// The boundary is in the FUTURE only because Import never reaches a past + /// day (it drops dates before now minus one day, which by time of day also + /// drops yesterday), so no boundary that satisfies I2 is reachable. The + /// lock itself does not check I2; this pins the skip as defense in depth. + /// + [Test] + public async Task Import_SkipsALockedDay_AndImportsTheRest() + { + const int microtingUid = 889; + const string siteName = "ImportLockSite"; + var lockedDay = DateTime.Now.AddDays(5).Date; + var openDay = lockedDay.AddDays(1); + + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + await new SdkSite { Name = siteName, MicrotingUid = microtingUid }.Create(sdkDbContext); + + var boundary = new PlanRegistration + { + SdkSitId = microtingUid, + Date = lockedDay, + PlanText = "LOCKED-ORIG", + Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await boundary.Create(TimePlanningPnDbContext); + var boundaryBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + + // The locked day comes first, so without the skip the import aborts + // before it ever reaches the open day. + var xlsx = BuildWorkbook(siteName, + (lockedDay.ToString("dd.MM.yyyy"), "8", "IMPORTED-LOCKED"), + (openDay.ToString("dd.MM.yyyy"), "6", "IMPORTED-OPEN")); + + var result = await _service.Import(FormFile(xlsx)); + + Assert.That(result.Success, Is.True, result.Message); + + var lockedAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + Assert.That(lockedAfter.PlanText, Is.EqualTo("LOCKED-ORIG"), "a locked day must not be imported into"); + Assert.That(lockedAfter.Version, Is.EqualTo(boundaryBefore.Version)); + Assert.That(lockedAfter.UpdatedAt, Is.EqualTo(boundaryBefore.UpdatedAt)); + + var openRow = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .SingleAsync(x => x.SdkSitId == microtingUid && x.Date == openDay + && x.WorkflowState != Constants.WorkflowStates.Removed); + Assert.That(openRow.PlanText, Is.EqualTo("IMPORTED-OPEN"), "the open day is still imported"); + } + + private static IFormFile FormFile(byte[] xlsx) + { + var file = Substitute.For(); + file.CopyToAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + var target = ci.Arg(); + target.Write(xlsx, 0, xlsx.Length); + return Task.CompletedTask; + }); + return file; + } + private static Cell TextCell(string reference, string value) => new Cell { CellReference = reference, @@ -153,7 +218,8 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() CellValue = new CellValue(value) }; - private static byte[] BuildWorkbook(string sheetName, string dateStr, string hours, string text) + private static byte[] BuildWorkbook( + string sheetName, params (string Date, string Hours, string Text)[] rows) { using var ms = new MemoryStream(); using (var doc = SpreadsheetDocument.Create(ms, SpreadsheetDocumentType.Workbook)) @@ -178,10 +244,15 @@ private static byte[] BuildWorkbook(string sheetName, string dateStr, string hou header.Append(TextCell("A1", "Date"), TextCell("B1", "Hours"), TextCell("C1", "Text")); sheetData.Append(header); - // Data row (RowIndex 2): A=date, B=planHours, C=planText. - var data = new Row { RowIndex = 2 }; - data.Append(TextCell("A2", dateStr), NumberCell("B2", hours), TextCell("C2", text)); - sheetData.Append(data); + // Data rows (RowIndex 2..): A=date, B=planHours, C=planText. + for (var i = 0; i < rows.Length; i++) + { + var r = i + 2; + var data = new Row { RowIndex = (uint)r }; + data.Append(TextCell($"A{r}", rows[i].Date), NumberCell($"B{r}", rows[i].Hours), + TextCell($"C{r}", rows[i].Text)); + sheetData.Append(data); + } wbPart.Workbook.Save(); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index 697eacf6..9ca817cd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -203,9 +203,23 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr // Seed database SeedDatabase(connectionString); - // One-shot, idempotent repair of pauseNId corruption (last 7 days, - // 5-minute sites). Safe to run on every startup. - RepairCorruptedPauseIds(connectionString); + // One-shot, idempotent repair of pauseNId corruption on 5-minute sites, + // for rows dated after the rolling payroll cutoff + // (CorruptedPauseIdRepair.FirstUnlockedDate). Safe to run on every startup. + try + { + RepairCorruptedPauseIds(connectionString); + } + catch (DayLockedException ex) + { + // The repair skips locked days itself, so reaching this means that + // skip has a bug. The interceptor has already refused the write, + // and a skip bug in a one-shot repair must never take the whole + // host down, so report it and keep starting. Any other exception + // keeps its existing behaviour. + Console.WriteLine($"[CorruptedPauseIdRepair] stopped by the day lock, startup continues: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public void Configure(IApplicationBuilder appBuilder) @@ -916,8 +930,10 @@ public void SeedDatabase(string connectionString) public void RepairCorruptedPauseIds(string connectionString) { - var contextFactory = new TimePlanningPnContextFactory(); - using var dbContext = contextFactory.CreateDbContext([connectionString]); + // Not TimePlanningPnContextFactory: that context has no day-lock + // interceptor, and this repair writes PlanRegistration rows. The + // helper builds the same options with the interceptor attached. + using var dbContext = new TimePlanningDbContextHelper(connectionString).GetDbContext(); CorruptedPauseIdRepair.Run(dbContext).GetAwaiter().GetResult(); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs index ba345569..52e5c304 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs @@ -16,7 +16,9 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; /// after the rolling payroll-lock cutoff, see FirstUnlockedDate) and to /// 5-minute sites. Recomputes pauseNId from the intact pause timestamps, /// writing only when a row is both clearly corrupted and confidently -/// repairable. Safe to run on every startup. +/// repairable. Safe to run on every startup. That payroll cutoff is not the +/// reconciled day lock: rows at or before a worker's reconciled boundary are +/// skipped as well, see Run. /// /// Netto consistency: on 5-minute (flag-off) sites the mobile save path /// (TimePlanningPlanningService.UpdateByCurrentUserNam) persists netto via the @@ -90,15 +92,31 @@ public static async Task Run(TimePlanningPnDbContext dbContext) .ToListAsync() .ConfigureAwait(false); + // The window is a fixed calendar cutoff, which can sit BEFORE a + // worker's reconciled boundary, so it reaches locked days. Frozen means + // frozen: a locked row is skipped before anything mutates it. It stays + // tracked but Unchanged, so no later save in this run can flush it. + // One query for every site in the scan, never one per row. + var lockedThroughBySite = await DayLockHelper + .LockedThroughForSitesAsync(dbContext, fiveMinuteSiteIds) + .ConfigureAwait(false); + // Observability counters (steps 1-3). They do not influence detection // or correction behaviour in any way. var rowsScanned = 0; var rowsCorrected = 0; var slotsCorrected = 0; var anomaliesFlagged = 0; + var rowsLocked = 0; foreach (var pr in rows) { + if (DayLockHelper.IsLocked(lockedThroughBySite, pr.SdkSitId, pr.Date)) + { + rowsLocked++; + continue; + } + rowsScanned++; // Worked span of shift 1, used only to gauge whether an @@ -156,7 +174,7 @@ public static async Task Run(TimePlanningPnDbContext dbContext) } // Step 3: run summary. - Console.WriteLine($"[CorruptedPauseIdRepair] summary: scanned {rowsScanned} rows, corrected {rowsCorrected} rows ({slotsCorrected} slots), flagged {anomaliesFlagged} anomalies."); + Console.WriteLine($"[CorruptedPauseIdRepair] summary: scanned {rowsScanned} rows, corrected {rowsCorrected} rows ({slotsCorrected} slots), flagged {anomaliesFlagged} anomalies, skipped {rowsLocked} locked rows."); } /// diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index c1a5abdb..7f9b2487 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -66,6 +66,15 @@ public static class DayLockHelper public static bool IsLocked(DateTime? lockedThrough, DateTime date) => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + /// + /// against a boundary map from + /// . A site missing from the map + /// has no boundary, so its days are open. + /// + public static bool IsLocked( + IReadOnlyDictionary lockedThroughBySite, int sdkSitId, DateTime date) + => IsLocked(lockedThroughBySite.GetValueOrDefault(sdkSitId), date); + /// /// The rows NOT locked by , as a filter the /// database runs. Exactly equivalent to !IsLocked(lockedThrough, x.Date), @@ -87,6 +96,30 @@ public static IQueryable WhereOpen( return query.Where(x => x.Date >= firstOpenDay); } + /// + /// The message key for a write the lock refuses. It states what the + /// blocking day IS: reconciled itself, or locked by a later reconciled day. + /// One rule for every path, so web and mobile say the same (spec §11.3). + /// + public static string LockedMessageKey(bool blockingRowIsReconciled) + => blockingRowIsReconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay"; + + /// + /// for a locked day whose row is not loaded, + /// or does not exist (then the day is only locked). One cheap query, so + /// call it only once the day is known to be locked. + /// + public static async Task LockedMessageKeyAsync( + TimePlanningPnDbContext db, int sdkSitId, DateTime date) + { + var day = date.Date; + var reconciled = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .AnyAsync(x => x.SdkSitId == sdkSitId && x.Date == day && x.Reconciled) + .ConfigureAwait(false); + return LockedMessageKey(reconciled); + } + /// /// Invariant I2: today and future days must stay open so time can still be /// registered. This is also what makes the forward flex cascades unable to diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index 54a8a3fd..a62e6997 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -258,6 +258,14 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn await OneMinuteModeTimeline.BuildAsync(dbContext, mappedAssignedSite); } + // This is a bulk re-sync over the sheet's whole history, so a locked + // day is skipped, never rejected: frozen means frozen. ONE query for + // every mapped site, built before the row loop and never per row. + // The timeline keys ARE the mapped sites: one per distinct non-null + // MicrotingUid in columnSiteMap. + var lockedThroughBySite = await DayLockHelper.LockedThroughForSitesAsync( + dbContext, oneMinuteTimelines.Keys.ToList()); + // Skip the header row (first row) for (var i = 1; i < values.Count; i++) { @@ -287,6 +295,14 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn continue; } + // Decided before the row is even loaded, so a locked row is + // never tracked and no later save in this loop can flush it. + if (site.MicrotingUid is { } lockSiteUid + && DayLockHelper.IsLocked(lockedThroughBySite, lockSiteUid, dateValue)) + { + continue; + } + Console.WriteLine($"Processing site: {site.Name}"); var planHours = row.Count > j ? row[j].ToString() : string.Empty; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs index 92cc2fdc..7f7e7853 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs @@ -239,6 +239,22 @@ public async Task ApproveAsync(int absenceRequestId, AbsenceReq return new OperationResult(false, _localizationService.GetString("AbsenceRequestMustBePending")); } + // Checked before anything is persisted: the status below is saved + // BEFORE the per-day loop, so letting the lock refuse a day + // part-way through would leave the request Approved with only some + // of its days flagged. One boundary query for the worker. The + // earliest locked day blocks; the message says what that day is. + var lockedThrough = await DayLockHelper.LockedThroughAsync(_dbContext, request.RequestedBySdkSitId); + var blockingDay = request.Days! + .OrderBy(day => day.Date) + .FirstOrDefault(day => DayLockHelper.IsLocked(lockedThrough, day.Date)); + if (blockingDay != null) + { + return new OperationResult(false, _localizationService.GetString( + await DayLockHelper.LockedMessageKeyAsync( + _dbContext, request.RequestedBySdkSitId, blockingDay.Date))); + } + // Apply changes without an explicit transaction. // NOTE: Update() methods internally handle persistence, and using // an explicit BeginTransactionAsync here was causing a silent diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index 7fffcc67..8b7d25f8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -644,6 +644,24 @@ public async Task AcceptAsync( "[Handover] Accept request {RequestId}: loaded fromPR {FromPRId} (sdkSitId={FromSdkSitId}) and toPR {ToPRId} (sdkSitId={ToSdkSitId}), shiftIndex={ShiftIndex}", requestId, fromPR.Id, fromPR.SdkSitId, toPR.Id, toPR.SdkSitId, request.ShiftIndex); + // Two different workers, each with their own boundary. Checked + // before either row is touched: the receiver is persisted before + // the sender, so a lock refusing the sender afterwards would leave + // the shift on both days. Checked in that persist order, and the + // message says what the blocking row is. + var boundaries = await DayLockHelper.LockedThroughForSitesAsync( + _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); + var blockingRow = new[] { toPR, fromPR }.FirstOrDefault(pr => + DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + if (blockingRow != null) + { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: rejected — PR {BlockingPRId} (sdkSitId={BlockingSdkSitId}) is on a locked day", + requestId, blockingRow.Id, blockingRow.SdkSitId); + return new OperationResult(false, _localizationService.GetString( + DayLockHelper.LockedMessageKey(blockingRow.Reconciled))); + } + // Resolve AssignedSites once (used by recalc helpers in both paths). var fromAssignedSite = await _dbContext.AssignedSites .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs index efda3848..01636396 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs @@ -147,6 +147,28 @@ public async Task UpdateCreate(List x.Worker?.Id) + .OfType() + .Distinct() + .ToList(); + var postedBoundaries = await DayLockHelper.LockedThroughForSitesAsync(dbContext, postedSiteIds); + // The first locked entry in posted order is the one that would + // have blocked a row-by-row save; the message says what that day is. + var blocking = model.FirstOrDefault(x => x.Worker?.Id is { } siteId + && DayLockHelper.IsLocked(postedBoundaries, siteId, x.Date)); + if (blocking != null) + { + return new OperationResult( + false, + localizationService.GetString(await DayLockHelper.LockedMessageKeyAsync( + dbContext, blocking.Worker.Id.Value, blocking.Date))); + } + foreach (var updateModel in model) { var planRegistration = await dbContext.PlanRegistrations @@ -174,6 +196,12 @@ public async Task UpdateCreate(List UpdateCreate(List x.StatusCaseId != 0) .Where(x => x.Date > DateTime.Now.AddDays(-2)) .Where(x => x.SdkSitId == listSiteId) + .WhereOpen(followUpBoundaries.GetValueOrDefault(listSiteId)) .ToListAsync(); foreach (PlanRegistration planRegistration in plannings) 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 f002e6d4..08c4c96d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -2313,7 +2313,7 @@ private void CompareBoolField(List changes, string fieldName, bool? var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); return DayLockHelper.IsLocked(lockedThrough, planning.Date) ? new OperationResult(false, localizationService.GetString( - planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")) + DayLockHelper.LockedMessageKey(planning.Reconciled))) : null; } @@ -2516,7 +2516,7 @@ public async Task> ReconcileThr { // Already at or past the target: moving the boundary BACK would // be an unlock, which is deliberately a separate, heavier action. - if (DayLockHelper.IsLocked(boundaries.GetValueOrDefault(siteId), target)) + if (DayLockHelper.IsLocked(boundaries, siteId, target)) { result.SkippedAlreadyFurtherForward.Add(siteId); continue; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index 11ad6f9a..ec463a48 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -1380,12 +1380,8 @@ private static void ApplyPunchClockFlexChainDecimal( return null; } - var day = date.Date; - var reconciled = await dbContext.PlanRegistrations - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .AnyAsync(x => x.SdkSitId == siteId && x.Date == day && x.Reconciled); return new OperationResult(false, localizationService.GetString( - reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + await DayLockHelper.LockedMessageKeyAsync(dbContext, siteId, date))); } public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model) @@ -4037,6 +4033,12 @@ public async Task Import(IFormFile file) .FirstOrDefaultAsync(x => x.SiteId == site.MicrotingUid); var importTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, importAssignedSite); + // A bulk import skips locked days (frozen means frozen) + // rather than failing the whole file. Once per sheet + // (= per site), never per row. + var importLockedThrough = site.MicrotingUid is { } importSiteUid + ? await DayLockHelper.LockedThroughAsync(dbContext, importSiteUid) + : null; var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value); var sheetData = worksheetPart.Worksheet.Elements().First(); @@ -4090,6 +4092,13 @@ public async Task Import(IFormFile file) continue; } + // Before the row is loaded, so a locked row is never + // tracked and no later save can flush a change into it. + if (DayLockHelper.IsLocked(importLockedThrough, dateValue)) + { + continue; + } + var preTimePlanning = await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < dateValue && x.SdkSitId == (int)site.MicrotingUid!) From d1961c5abc5828c9ada0ea8622a3c671d07c37fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 17:13:18 +0200 Subject: [PATCH 13/18] feat(lock): send the reconciled state and boundary to the client Each day carries Reconciled/ReconciledAt, and each row carries LockedThrough, set even when the worker has no rows in the window. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 77 +++++++++++++++++++ .../Helpers/PlanRegistrationHelper.cs | 6 ++ .../Planning/TimePlanningPlanningModel.cs | 6 ++ .../TimePlanningPlanningPrDayModel.cs | 9 +++ 4 files changed, 98 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index c293dc16..a57bcbff 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -973,4 +973,81 @@ public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() "the missing locked dates are placeholders on the mobile path too"); }); } + + // --------------------------------------------------------------------- + // Task 6: the read model carries the lock state -- the row-level boundary + // once per site, and Reconciled/ReconciledAt per day, so the client can + // render locked and reconciled days without scanning cells. + // --------------------------------------------------------------------- + + [Test] + public async Task Index_ProjectsReconciledStateOntoTheReadModel() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(912); + // Locked by derivation only -- earlier than the boundary, never itself + // marked Reconciled. + var earlier = await SeedPlain(912, DateTime.Now.Date.AddDays(-6)); + var boundary = await SeedReconciledBoundaryAsync(912, DateTime.Now.Date.AddDays(-4)); + var window = new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-6), + DateTo = DateTime.Now.Date + }; + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + var siteRow = result.Model.Single(x => x.SiteId == 912); + var boundaryDay = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == boundary.Date.Date); + var earlierDay = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == earlier.Date.Date); + Assert.Multiple(() => + { + Assert.That(siteRow.LockedThrough, Is.EqualTo(boundary.Date), + "the client needs the boundary once per row, not per cell"); + Assert.That(boundaryDay.Reconciled, Is.True); + Assert.That(boundaryDay.ReconciledAt, Is.Not.Null); + Assert.That(earlierDay.Reconciled, Is.False, + "locked by derivation, not individually marked -- LockedThrough covers it"); + }); + } + + /// + /// The trap the brief names: UpdatePlanRegistrationsInPeriod's per-day loop + /// runs over planningsInPeriod, and a window that is entirely locked with + /// no existing rows leaves that list empty for the whole call -- the loop + /// never executes once. LockedThrough must still be set, because it is + /// resolved unconditionally before the loop, not inside it. + /// + [Test] + public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLockedThrough() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(914); + var boundary = await SeedReconciledBoundaryAsync(914, DateTime.Now.Date.AddDays(-5)); + // Entirely before the boundary, so every day here is locked -- and + // gap-fill must not create rows inside a frozen period, so this + // window has zero PlanRegistrations of its own. + var window = new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-20), + DateTo = DateTime.Now.Date.AddDays(-15) + }; + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + var siteRow = result.Model.Single(x => x.SiteId == 914); + var rowsInWindow = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 914 && x.Date >= window.DateFrom && x.Date <= window.DateTo); + Assert.Multiple(() => + { + Assert.That(rowsInWindow, Is.Zero, "the window must genuinely have no rows of its own"); + Assert.That(siteRow.LockedThrough, Is.EqualTo(boundary.Date), + "a fully-locked window with no rows must still carry the boundary, " + + "or the client renders an entirely locked worker as fully editable"); + }); + } } 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 c22d7bdf..5a934c36 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -424,6 +424,10 @@ public static async Task UpdatePlanRegistrationsInPer // skipped rather than raising. Without this, the interceptor turns // every visit to a closed month into a 500. var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); + // Set once, here, before the per-day loop: a worker with NO rows in + // the window never enters that loop at all, and would otherwise leave + // LockedThrough null — rendering a fully-locked month as editable. + siteModel.LockedThrough = lockedThrough; foreach (var plan in planningsInPeriod) { var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id); @@ -1130,6 +1134,8 @@ await dbContext.PlanRegistrations.AsNoTracking() planningModel.IsDoubleShift = planningModel.Start2StartedAt != planningModel.Stop2StoppedAt; planningModel.NettoHoursOverride = planRegistration.NettoHoursOverride; planningModel.NettoHoursOverrideActive = planRegistration.NettoHoursOverrideActive; + planningModel.Reconciled = planRegistration.Reconciled; + planningModel.ReconciledAt = planRegistration.ReconciledAt; // Approach C READ projection: for any shift with a pause override, // present a single synthesized pause pair (sum = override) and empty diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs index e753b1d9..63bd5ac6 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs @@ -22,6 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +using System; using System.Collections.Generic; using Microting.eFormApi.BasePn.Infrastructure.Models.Common; @@ -75,5 +76,10 @@ public class TimePlanningPlanningModel public bool ThirdShiftActive { get; set; } public bool FourthShiftActive { get; set; } public bool FifthShiftActive { get; set; } + /// The worker's newest reconciled day -- every day at or before this + /// is locked, and only this day can be unlocked. Null when nothing is + /// reconciled. Sent once per row so the client compares dates instead of + /// scanning cells. + public DateTime? LockedThrough { get; set; } public List PlanningPrDayModels { get; set; } } \ No newline at end of file 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 f4bbd34f..73a5e887 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 @@ -234,4 +234,13 @@ public class TimePlanningPlanningPrDayModel public DateTime? Pause5StoppedAt { get; set; } public double NettoHoursOverride { get; set; } public bool NettoHoursOverrideActive { get; set; } + + /// The stored mark: true on every day the worker reconciled (there + /// can be several). Days before the newest mark are locked without being + /// marked. Whether a day is locked, and which day can be unlocked, comes + /// from LockedThrough on the row, never from this flag alone. + public bool Reconciled { get; set; } + + /// When this day was reconciled (server local time); null when not reconciled. + public DateTime? ReconciledAt { get; set; } } \ No newline at end of file From 7d2d7e341ca2fb7e9861a969fbe1cb2c5d4b715d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 18:01:14 +0200 Subject: [PATCH 14/18] fix(lock): close the final-review gaps before merge - a test proves both production context paths attach the lock; - unlock refusals name the day to free first; - create-time lock checks for absence and handover requests; - lock refusals are no longer mislabelled or swallowed; - self-contained comments, twin headers, and skip counts logged. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../AbsenceRequestServiceTests.cs | 41 ++++++++++ .../ContentHandoverServiceTests.cs | 67 +++++++++++++++++ .../DayLockHelperTests.cs | 12 +-- .../DayLockInterceptorTests.cs | 11 +-- .../DayLockWiringTests.cs | 74 +++++++++++++++++++ ...yrollExportRemovedPlanRegistrationTests.cs | 2 +- .../ReconcileServiceTests.cs | 63 +++++++++++----- .../TimePlanning.Pn.Test/TestBaseSetup.cs | 13 +++- .../EformTimePlanningPlugin.cs | 29 ++++++-- .../Infrastructure/Helpers/DayLockHelper.cs | 6 ++ .../Helpers/GoogleSheetHelper.cs | 5 ++ .../Helpers/PlanRegistrationHelper.cs | 8 +- .../ReconciledDayLockInterceptor.cs | 24 +++++- .../Resources/Translations.Designer.cs | 6 ++ .../Resources/Translations.da.resx | 5 +- .../Resources/Translations.resx | 5 +- .../AbsenceRequestService.cs | 12 +++ .../ContentHandoverService.cs | 36 ++++++--- .../Services/RebusService/RebusService.cs | 10 +-- .../TimePlanningPlanningService.cs | 33 ++++++--- .../TimePlanningWorkingHoursService.cs | 19 ++++- 23 files changed, 407 insertions(+), 78 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index bb53b746..7dd7fbf9 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockWiringTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 969029ca..bbc4a5e1 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockWiringTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs index 027ee2d4..02774a3e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs @@ -116,6 +116,47 @@ public async Task CreateAsync_RejectsOverlappingPendingRequest() Assert.That(result.Message, Is.EqualTo("OverlappingAbsenceRequestExists")); } + /// + /// A request touching a locked day could never be approved, so it must + /// not be created and left pending. The range runs past the boundary into + /// an open day, so only the lock check can refuse it. The message says + /// what the first requested day IS: locked by the boundary (no row of its + /// own), or the reconciled boundary day itself. + /// + [TestCase(3, "DayIsLockedByReconciledDay", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheFirstDayIsBelowTheBoundary")] + [TestCase(4, "DayIsReconciled", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheFirstDayIsTheReconciledDay")] + public async Task CreateAsync_RefusesAndCreatesNothing_WhenTheFirstRequestedDayIsLocked( + int firstRequestedDayOfMarch, string expectedMessage) + { + const int sdkSitId = 12; + await new PlanRegistration + { + SdkSitId = sdkSitId, + Date = new DateTime(2024, 3, 4), + Reconciled = true, + ReconciledAt = new DateTime(2024, 3, 6, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + + var result = await _absenceRequestService.CreateAsync(new AbsenceRequestCreateModel + { + RequestedBySdkSitId = sdkSitId, + DateFrom = new DateTime(2024, 3, firstRequestedDayOfMarch), + DateTo = new DateTime(2024, 3, 6), + MessageId = 2, // Vacation + RequestComment = "Spans the boundary" + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + Assert.That(await TimePlanningPnDbContext.AbsenceRequests.AsNoTracking().CountAsync(), Is.Zero, + "a request that can never be approved must not be created"); + Assert.That(await TimePlanningPnDbContext.AbsenceRequestDays.AsNoTracking().CountAsync(), Is.Zero); + } + [Test] public async Task ApproveAsync_UpdatesPlanRegistrations_AndSetsAbsenceFlags() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index 56b0edc0..229b32eb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -244,6 +244,73 @@ public async Task CreateAsync_CreatesHandoverRequest_WhenSourceHasOnlyDoublePlan Assert.That(result.Model[0].ShiftIndex, Is.Null); } + /// + /// A handover touching a locked day could never be accepted, so it must + /// not be created and left pending. Otherwise a valid full-day request: + /// the source has content and the target is empty. The message says what + /// the blocking row IS, on whichever worker's row blocks: locked by a + /// later reconciled day, or reconciled itself. + /// + [TestCase(true, false, "DayIsLockedByReconciledDay", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheSendersDayIsLocked")] + [TestCase(false, true, "DayIsReconciled", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheReceiversDayIsReconciled")] + public async Task CreateAsync_RefusesAndCreatesNothing_WhenEitherWorkersDayIsLocked( + bool lockSender, bool reconcileTheHandoverDay, string expectedMessage) + { + var date = new DateTime(2024, 4, 8); + var sourcePR = new PlanRegistration + { + Date = date, + SdkSitId = 1, + PlanHoursInSeconds = 28800, + PlanText = "Important work", + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var targetPR = new PlanRegistration + { + Date = date, + SdkSitId = 2, + PlanHoursInSeconds = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var lockedWorkersRow = lockSender ? sourcePR : targetPR; + if (reconcileTheHandoverDay) + { + // The handover day itself is the locked worker's boundary. + lockedWorkersRow.Reconciled = true; + lockedWorkersRow.ReconciledAt = new DateTime(2024, 4, 9, 9, 12, 0); + } + await sourcePR.Create(TimePlanningPnDbContext); + await targetPR.Create(TimePlanningPnDbContext); + + if (!reconcileTheHandoverDay) + { + // Lock-safe order: the handover day exists BEFORE a later day of + // the locked worker is reconciled, which locks the handover day too. + await new PlanRegistration + { + Date = date.AddDays(2), + SdkSitId = lockedWorkersRow.SdkSitId, + Reconciled = true, + ReconciledAt = new DateTime(2024, 4, 11, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var result = await _contentHandoverService.CreateAsync(sourcePR.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, RequestComment = "Locked day" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + Assert.That(await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests + .AsNoTracking().CountAsync(), Is.Zero, + "a handover that can never be accepted must not be created"); + } + [Test] public async Task AcceptAsync_MovesContent_FromSourceToTarget() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index a64e9847..4159fcbd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -71,9 +71,9 @@ public async Task LockedThrough_SeveralReconciled_IsTheLatest() [Test] public async Task LockedThrough_IgnoresRemovedRows() { - // Once Task 2's interceptor exists, any write that touches a - // PlanRegistration at or before the current boundary (I3) is - // rejected. That rules out the obvious seeding -- reconcile a row, + // The fixture context carries the day-lock interceptor, so any write + // that touches a PlanRegistration at or before the current boundary + // (I3) is rejected. That rules out the obvious seeding -- reconcile a row, // then soft-delete it in a LATER, separate save -- because by the // time of that second save the boundary would already be the row // being deleted, and the delete would land ON the boundary day. @@ -85,9 +85,9 @@ public async Task LockedThrough_IgnoresRemovedRows() // it via Delete() (PnBase.Delete only ever issues one SaveChanges // when there are pending changes). At the moment that save runs, the // DB boundary is still the 12th, so the write to the 18th is above - // the boundary and permitted -- both before Task 2's interceptor - // exists and after. The row ends up Removed, so it must never - // surface as the new boundary. + // the boundary and permitted, with or without the interceptor + // attached. The row ends up Removed, so it must never surface as the + // new boundary. await Seed(702, new DateTime(2026, 1, 12), reconciled: true); await Seed(702, new DateTime(2026, 1, 18), reconciled: false); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs index 08510ebf..085b3e30 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs @@ -10,10 +10,11 @@ namespace TimePlanning.Pn.Test; /// -/// The interceptor is the only layer that is COMPLETE -- it covers all 32 -/// PlanRegistration write sites and anything added later. These tests go -/// through a context built exactly as production builds it (interceptor -/// attached), so they prove the wiring, not just the class. +/// The interceptor is the only layer that covers every write path -- all 32 +/// PlanRegistration write sites and anything added later (see its race note +/// for the one gap). These tests prove the class's rule through the fixture +/// context, which attaches the interceptor itself; DayLockWiringTests proves +/// that production's own context builders attach it too. /// [TestFixture] public class DayLockInterceptorTests : TestBaseSetup @@ -152,7 +153,7 @@ public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() [Test] public async Task SettingTheTransferredToPayrollFlag_OnALockedDay_IsAllowed() { - // Mirrors PayrollExportService.ExportPayroll exactly (ruling F10): + // Mirrors PayrollExportService.ExportPayroll exactly (spec §11.2): // Reconciled and TransferredToPayroll are independent, so exporting a // reconciled period must not be blocked by the very lock reconciling // it created. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs new file mode 100644 index 00000000..08de50c3 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The fixture attaches the day-lock interceptor to its own contexts, so every +/// other lock test would stay green if production stopped attaching it. These +/// tests go through production's two context builders instead: deleting the +/// interceptor from either one switches the lock off, and must fail here. +/// +[TestFixture] +public class DayLockWiringTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + [Test] + public async Task TheContextHelper_AttachesTheLock() + { + // Seeded through the fixture context, in lock-safe order: the earlier + // row first, then the reconciled boundary that locks it. + var earlier = new PlanRegistrationEntity + { + SdkSitId = 820, Date = new DateTime(2026, 1, 13), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await earlier.Create(TimePlanningPnDbContext!); + await new PlanRegistrationEntity + { + SdkSitId = 820, Date = new DateTime(2026, 1, 16), Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + + await using var productionContext = + new TimePlanningDbContextHelper(PluginConnectionString).GetDbContext(); + var locked = await productionContext.PlanRegistrations + .AsTracking() + .FirstAsync(x => x.Id == earlier.Id); + locked.PlanHours = 9; + + Assert.ThrowsAsync(async () => await locked.Update(productionContext)); + } + + [Test] + public void ThePooledRegistration_AttachesTheLock() + { + // An explicit server version, so building the options opens no + // connection; production passes ServerVersion.AutoDetect here. + var builder = new DbContextOptionsBuilder(); + EformTimePlanningPlugin.ConfigureTimePlanningDbContext( + builder, PluginConnectionString, new MariaDbServerVersion(new Version(10, 5, 0))); + + // Null-safe, so a missing interceptor fails the assertion below rather + // than throwing: without AddInterceptors there may be no interceptor + // list, or no core extension at all. + var interceptors = builder.Options.FindExtension()?.Interceptors ?? []; + + Assert.That(interceptors, Does.Contain(ReconciledDayLockInterceptor.Instance)); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs index 1c21a814..12ee52ee 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs @@ -135,7 +135,7 @@ public async Task ExportPayroll_TreatsRemovedPlanRegistrationPayLineAsNoData() } /// - /// Spec §11.2 (ruling F10): reconciling a period must not block exporting + /// Spec §11.2: reconciling a period must not block exporting /// it. Export flags every exported row, locked or not, so without the /// interceptor's payroll-flag exemption the first locked row's Update is /// refused and the export fails with the file already produced. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index a57bcbff..e0c05603 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -67,6 +68,10 @@ public async Task SetUpTest() _localizationService = Substitute.For(); _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + // The format overload echoes its arguments, so a test can assert what + // a message names (e.g. the day to unlock first), not just its key. + _localizationService.GetString(Arg.Any(), Arg.Any()) + .Returns(x => x[0] + "|" + string.Join("|", (object[])x[1])); _coreService = Substitute.For(); var core = await GetCore(); @@ -170,10 +175,11 @@ private async Task BuildAdminIndexServiceAsync(Bas /// /// UpdatePlanRegistrationsInPeriod's catch-all logs and SWALLOWS whatever - /// its try throws, so a lock rejection there (a missing guard on an Update - /// inside the try) would not fail Index. It does log at Error level, which - /// this catches. Inspects ReceivedCalls, because LogError is an extension - /// method over the generic Log<TState> and cannot be matched directly. + /// its try throws, except a lock rejection, which it lets through to fail + /// Index. Anything else swallowed there would not fail Index, but it does + /// log at Error level, which this catches. Inspects ReceivedCalls, because + /// LogError is an extension method over the generic Log<TState> and + /// cannot be matched directly. /// private void AssertNoErrorLogged() { @@ -184,7 +190,8 @@ private void AssertNoErrorLogged() /// /// The dashboard grid reads days BY POSITION, so the list must hold - /// exactly one entry per date of the window, in order (ruling F17). + /// exactly one entry per date of the window, in order (spec §6.3); a + /// missing entry would shift every later day one column left. /// private static void AssertOneDayPerDate( IReadOnlyList days, TimePlanningPlanningRequestModel window) @@ -232,7 +239,8 @@ private async Task SeedReconciledBoundaryAsync(int siteUid, Da /// /// Everything Index() needs to actually process a site: the SDK Site the /// row takes its name from and the plugin AssignedSite it iterates. Without - /// both, Index() skips the site and a lock test passes vacuously (ruling F7). + /// both, Index() skips the site, so a lock test would pass without ever + /// exercising the lock. /// private async Task SeedAssignedSiteAsync(int siteUid, bool useGoogleSheetAsDefault = false) { @@ -285,8 +293,9 @@ private async Task SeedCurrentUserWorkerAsync(SdkSite sdkSite) /// /// A reconciled day (so the boundary) storing values a dashboard load /// rewrites in memory. That makes the loaded, TRACKED entity dirty -- the - /// precondition for the flush trap in ruling F15. Without it, merely - /// skipping the Update calls would look sufficient. + /// precondition for the flush trap: the next open day's save would flush + /// the dirty locked row and fail the load. Without it, merely skipping the + /// Update calls would look sufficient. /// /// IsSaturday is stored WRONG for the date, so the unconditional weekday /// assignment before the first Update (the one outside the try, whose @@ -409,8 +418,22 @@ public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay var result = await _service.Unreconcile(earlier.Id); Assert.That(result.Success, Is.False); - Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), - "the puzzle rule: free the outermost piece first"); + Assert.That(result.Message, + Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked|" + + later.Date.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture)), + "the puzzle rule: free the outermost piece first, and the message names it"); + } + + [Test] + public async Task Unreconcile_WhenNothingIsReconciled_SaysSo() + { + // No boundary at all, so there is no day the message could name. + var row = await SeedPlain(915, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Unreconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("NothingIsReconciled")); } [Test] @@ -680,9 +703,8 @@ public async Task CreateUpdate_AcrossTheBoundary_SkipsLockedRowsAndSavesOpenOnes /// WhereOpen keeps locked ROWS out of the load, but a locked date with NO /// row (a gap row the working-hours Index emits, and the page posts every /// row) is not in the load either way. Without the skip, CreatePlanning - /// creates -5 inside the lock; its own catch swallows the interceptor's - /// throw, the Added entity stays tracked, the next save (-2's create, then - /// -1's update, which has no catch) flushes it again, and the request fails. + /// creates -5 inside the lock, the interceptor refuses it, and the refusal + /// propagates through CreatePlanning's catch and fails the whole request. /// [Test] public async Task CreateUpdate_ALockedGapRowInTheMiddle_IsNotCreatedAndTheRestSaves() @@ -862,15 +884,16 @@ public async Task UpdateWorkingHour_Personal_TheBoundaryDay_ReturnsDayIsReconcil // --------------------------------------------------------------------- // Layer 3: recalculation skips locked days, and a dashboard load over a - // closed period still succeeds (ruling F15: a dirty locked entity left - // behind would be flushed by the next open day's save and fail the load). - // Each asserts the positional one-entry-per-date contract (ruling F17) and - // that nothing was rejected and swallowed inside the recompute's catch. + // closed period still succeeds (a dirty locked entity left behind would be + // flushed by the next open day's save and fail the load). Each asserts the + // positional one-entry-per-date contract (spec §6.3) and that nothing on + // the way was logged as an error. // --------------------------------------------------------------------- /// /// Run once per recompute branch: each branch has its own guarded Update - /// inside the catch-all, and AssertNoErrorLogged is what pins it. + /// inside the catch-all. A missing guard there throws the lock rejection + /// through the catch-all, which fails Index and so result.Success. /// [TestCase(false)] [TestCase(true)] @@ -975,7 +998,7 @@ public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() } // --------------------------------------------------------------------- - // Task 6: the read model carries the lock state -- the row-level boundary + // The read model carries the lock state -- the row-level boundary // once per site, and Reconciled/ReconciledAt per day, so the client can // render locked and reconciled days without scanning cells. // --------------------------------------------------------------------- @@ -1014,7 +1037,7 @@ public async Task Index_ProjectsReconciledStateOntoTheReadModel() } /// - /// The trap the brief names: UpdatePlanRegistrationsInPeriod's per-day loop + /// The trap: UpdatePlanRegistrationsInPeriod's per-day loop /// runs over planningsInPeriod, and a window that is entirely locked with /// no existing rows leaves that list empty for the whole call -- the loop /// never executes once. LockedThrough must still be set, because it is diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 365089de..adafa976 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -33,7 +33,7 @@ private TimePlanningPnDbContext GetTimePlanningPnDbContext(string connectionStr) var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseMySql( - connectionStr.Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"), + PluginConnectionString, new MariaDbServerVersion( ServerVersion.AutoDetect(connectionStr)), mySqlOptionsAction: builder => { @@ -103,6 +103,15 @@ protected BaseDbContext GetBaseDbContext() return baseDbContext; } + /// + /// The connection string of the plugin database + /// migrates, for tests that must build a context the way production does + /// (e.g. through TimePlanningDbContextHelper) rather than through this + /// fixture's own builders. + /// + protected string PluginConnectionString => _mariadbTestcontainer.GetConnectionString() + .Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"); + /// /// Builds a NEW TimePlanningPnDbContext against the same (already /// migrated) plugin database as — @@ -116,7 +125,7 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseMySql( - connectionStr.Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"), + PluginConnectionString, new MariaDbServerVersion(ServerVersion.AutoDetect(connectionStr)), mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index 9ca817cd..948499af 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -181,14 +181,11 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr _connectionString = connectionString; services.AddSingleton(provider => new TimePlanningDbContextHelper(_connectionString)); + // Keep the options inside ConfigureTimePlanningDbContext: that is where + // DayLockWiringTests checks the day-lock interceptor is attached. services.AddDbContextPool(o => - o.UseMySql(connectionString, new MariaDbServerVersion( - ServerVersion.AutoDetect(connectionString)), mySqlOptionsAction: builder => - { - builder.EnableRetryOnFailure(); - builder.MigrationsAssembly(PluginAssembly().FullName); - }) - .AddInterceptors(ReconciledDayLockInterceptor.Instance)); + ConfigureTimePlanningDbContext(o, connectionString, + new MariaDbServerVersion(ServerVersion.AutoDetect(connectionString)))); var contextFactory = new TimePlanningPnContextFactory(); var context = contextFactory.CreateDbContext(new[] { connectionString }); @@ -222,6 +219,24 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr } } + /// + /// The options of the pooled TimePlanningPnDbContext registration. A + /// method, not an inline lambda, so DayLockWiringTests can prove the + /// day-lock interceptor is attached. The caller passes the server version + /// so that test opens no connection; the registration passes + /// ServerVersion.AutoDetect, as it always has. + /// + public static void ConfigureTimePlanningDbContext( + DbContextOptionsBuilder options, string connectionString, ServerVersion serverVersion) + { + options.UseMySql(connectionString, serverVersion, mySqlOptionsAction: builder => + { + builder.EnableRetryOnFailure(); + builder.MigrationsAssembly(typeof(EformTimePlanningPlugin).Assembly.FullName); + }) + .AddInterceptors(ReconciledDayLockInterceptor.Instance); + } + public void Configure(IApplicationBuilder appBuilder) { appBuilder.UseEndpoints(endpoints => diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index 7f9b2487..b4cf9f8e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -1,3 +1,9 @@ +// NOTE: a deliberate copy of this file lives in eform-service-timeplanning-plugin +// (ServiceTimePlanningPlugin/Infrastructure/Helpers/DayLockHelper.cs). The two +// repos share only the base NuGet package. If you change the lock logic here, +// change the twin too: a divergence lets background jobs write days the web +// refuses. The twin omits the message and reconcile members, which background +// jobs never need. #nullable enable namespace TimePlanning.Pn.Infrastructure.Helpers; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index a62e6997..0bb2f324 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -265,6 +265,8 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn // MicrotingUid in columnSiteMap. var lockedThroughBySite = await DayLockHelper.LockedThroughForSitesAsync( dbContext, oneMinuteTimelines.Keys.ToList()); + // Observability only: the skip is silent otherwise. + var lockedDaysSkipped = 0; // Skip the header row (first row) for (var i = 1; i < values.Count; i++) @@ -300,6 +302,7 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn if (site.MicrotingUid is { } lockSiteUid && DayLockHelper.IsLocked(lockedThroughBySite, lockSiteUid, dateValue)) { + lockedDaysSkipped++; continue; } @@ -481,6 +484,8 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn } } } + + Console.WriteLine($"[PullEverythingFromGoogleSheet] summary: skipped {lockedDaysSkipped} locked day(s)."); } else { 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 5a934c36..111462e5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -14,6 +14,7 @@ using Microting.TimePlanningBase.Infrastructure.Data.Entities; using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Models.Holiday; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -859,7 +860,12 @@ await dbContext.PlanRegistrations.AsNoTracking() } } } - catch (Exception e) + // A lock refusal passes through. Every Update in this try is + // skipped for a locked day, so one only arrives when the boundary + // moved mid-load or a guard is missing. It must fail the load, not + // be logged as a PlanText problem: swallowed, the rejected entry + // stays tracked and the next save fails on it anyway. + catch (Exception e) when (e is not DayLockedException) { logger.LogError( $"Could not parse PlanText for planning with id: {planRegistration.Id} the PlanText was: {planRegistration.PlanText}"); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs index 591eaf99..6c7f92d0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs @@ -1,3 +1,8 @@ +// NOTE: a deliberate copy of this file lives in eform-service-timeplanning-plugin +// (ServiceTimePlanningPlugin/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs). +// The two repos share only the base NuGet package. If you change the lock logic +// here, change the twin too: a divergence lets background jobs write days the +// web refuses. #nullable enable namespace TimePlanning.Pn.Infrastructure.Interceptors; @@ -36,7 +41,15 @@ public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThro } /// -/// Enforces invariant I3 across every PlanRegistration write path. +/// Enforces invariant I3 on every PlanRegistration write path; see the race +/// note below for the one gap. +/// +/// RACE NOTE. The boundary query and the write are separate statements with no +/// transaction around them, so a reconcile committed between the two lets one +/// write through onto a day that just became locked. This is race-only and +/// accepted. The services' up-front guards read the boundary earlier, but they +/// only pick the friendly message: a reconcile committed after a guard ran is +/// still refused here, at save time, as a DayLockedException. /// /// STATELESS BY DESIGN. The pooled context registration /// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an @@ -96,8 +109,10 @@ public override InterceptionResult SavingChanges( // Sync-over-async is safe here: ASP.NET Core requests run with no // SynchronizationContext, and GuardAsync/LockedThroughForSitesAsync use // ConfigureAwait(false) throughout, so there is no continuation to - // deadlock on. This path exists only because the plugin's own seed - // code calls the synchronous SaveChanges; those seeds never touch + // deadlock on. No production path reaches this: the plugin's seed code + // is the only caller of the synchronous SaveChanges, and in production + // it runs on the factory context, which has no interceptor. Only the + // test fixture seeds through a guarded context. The seeds never touch // PlanRegistration, so GuardAsync returns before awaiting anything. GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); return base.SavingChanges(eventData, result); @@ -184,7 +199,8 @@ private static async Task GuardAsync(DbContext? context, CancellationToken cance // The two permitted writes inside the locked range: clearing the // flag on the boundary day itself (that is what unlocking IS), and - // a payroll-flag-only write on any locked day (ruling F10). Both + // a payroll-flag-only write on any locked day (see + // PayrollFlagProperties for why exporting must stay possible). Both // are evaluated against the entry's CURRENT site's boundary -- // that is the only boundary either exemption is ever about -- and // both already fail whenever Date or SdkSitId is among the diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index a9854710..004dd543 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -333,6 +333,12 @@ internal static string OnlyLatestReconciledDayCanBeUnlocked { } } + internal static string NothingIsReconciled { + get { + return ResourceManager.GetString("NothingIsReconciled", resourceCulture); + } + } + internal static string SuccessfullyReconciledDay { get { return ResourceManager.GetString("SuccessfullyReconciledDay", resourceCulture); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index d4887ddb..9bb92427 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -211,7 +211,10 @@ Kun dage før i dag kan afstemmes. - Lås den seneste afstemte dag op først. + Lås den seneste afstemte dag op først ({0}). + + + Intet er afstemt for denne medarbejder. Dagen er afstemt diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index eaae564f..f1acdabb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -211,7 +211,10 @@ Only days before today can be reconciled. - Unlock the most recent reconciled day first. + Unlock the most recent reconciled day first ({0}). + + + Nothing is reconciled for this worker. Day reconciled diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs index 7f7e7853..ff97556d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs @@ -119,6 +119,18 @@ public async Task> CreateAsync(AbsenceR _localizationService.GetString("DateToMustBeGreaterThanOrEqualToDateFrom")); } + // A request touching a locked day could never be approved (Approve + // refuses it), so it is refused here instead of left pending. Locked + // days are a prefix of the calendar, so the range touches one + // exactly when its first day is locked, and that day is the earliest + // blocking one Approve would name too. + var lockedThrough = await DayLockHelper.LockedThroughAsync(_dbContext, model.RequestedBySdkSitId); + if (DayLockHelper.IsLocked(lockedThrough, dateFrom)) + { + return new OperationDataResult(false, _localizationService.GetString( + await DayLockHelper.LockedMessageKeyAsync(_dbContext, model.RequestedBySdkSitId, dateFrom))); + } + // Check for overlapping pending requests for the same worker var hasOverlap = await _dbContext.AbsenceRequests .AnyAsync(ar => ar.RequestedBySdkSitId == model.RequestedBySdkSitId diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index 8b7d25f8..968e72a4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -320,6 +320,15 @@ public async Task>> Create _localizationService.GetString("CannotHandoverToSameWorker")); } + // A handover touching a locked day could never be accepted (Accept + // refuses it), so it is refused here instead of left pending. + var blockingRow = await FirstLockedRowAsync(fromPR, toPR); + if (blockingRow != null) + { + return new OperationDataResult>(false, + _localizationService.GetString(DayLockHelper.LockedMessageKey(blockingRow.Reconciled))); + } + var shiftIndices = model.ShiftIndices ?? new List(); // Load existing pending requests scoped to (target, date). We'll @@ -462,6 +471,20 @@ public async Task>> Create } } + /// + /// The first of the two rows that sits on a locked day, or null. The rows + /// belong to two different workers, each with their own boundary, resolved + /// in one query. Checked so a refusal names the row that blocks it, in the + /// persist order Accept uses (receiver first, then sender). + /// + private async Task FirstLockedRowAsync(PlanRegistration fromPR, PlanRegistration toPR) + { + var boundaries = await DayLockHelper.LockedThroughForSitesAsync( + _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); + return new[] { toPR, fromPR }.FirstOrDefault(pr => + DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + } + private void FireCreatePush(int toSdkSitId, List requestIds, int shiftCount, DateTime date) { _ = Task.Run(() => SendCreatePushAsync(toSdkSitId, requestIds, shiftCount, date)); @@ -644,15 +667,10 @@ public async Task AcceptAsync( "[Handover] Accept request {RequestId}: loaded fromPR {FromPRId} (sdkSitId={FromSdkSitId}) and toPR {ToPRId} (sdkSitId={ToSdkSitId}), shiftIndex={ShiftIndex}", requestId, fromPR.Id, fromPR.SdkSitId, toPR.Id, toPR.SdkSitId, request.ShiftIndex); - // Two different workers, each with their own boundary. Checked - // before either row is touched: the receiver is persisted before - // the sender, so a lock refusing the sender afterwards would leave - // the shift on both days. Checked in that persist order, and the - // message says what the blocking row is. - var boundaries = await DayLockHelper.LockedThroughForSitesAsync( - _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); - var blockingRow = new[] { toPR, fromPR }.FirstOrDefault(pr => - DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + // Checked before either row is touched: the receiver is persisted + // before the sender, so a lock refusing the sender afterwards would + // leave the shift on both days. + var blockingRow = await FirstLockedRowAsync(fromPR, toPR); if (blockingRow != null) { _logger.LogWarning( diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs index 034c5b46..5ef7a4ac 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs @@ -28,10 +28,10 @@ namespace TimePlanning.Pn.Services.RebusService; using Castle.MicroKernel.Registration; using Castle.Windsor; using eFormCore; +using Infrastructure.Helpers; using Installers; using Microting.eFormApi.BasePn.Abstractions; using Microting.TimePlanningBase.Infrastructure.Data; -using Microting.TimePlanningBase.Infrastructure.Data.Factories; using Rebus.Bus; public class RebusService : IRebusService @@ -65,9 +65,9 @@ public IBus GetBus() { return _bus; } + // The day-lock guarded context, not TimePlanningPnContextFactory's: a + // future handler writing PlanRegistration through this registration must + // not bypass the lock. private TimePlanningPnDbContext GetContext() - { - TimePlanningPnContextFactory contextFactory = new TimePlanningPnContextFactory(); - return contextFactory.CreateDbContext(new[] {_connectionString}); - } + => new TimePlanningDbContextHelper(_connectionString).GetDbContext(); } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 08c4c96d..5bc39273 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -36,6 +36,7 @@ namespace TimePlanning.Pn.Services.TimePlanningPlanningService; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Infrastructure.Models.Planning; @@ -2426,9 +2427,9 @@ public async Task Reconcile(int id) // Expected and routine: a blocked edit is a normal outcome, not an // incident. Do not report it to Sentry. // - // The rejected entry stays tracked on `dbContext` (a request-scoped, - // injected context — see Task 4 amendment A1) after this throws. - // That is acceptable because we return immediately; no further + // The rejected entry stays tracked on `dbContext` after this throws. + // That context is the request-scoped, injected one, so the entry + // dies with the request, and we return immediately: no further // save is attempted on this context for the rest of the request. return new OperationResult(false, localizationService.GetString("DayIsLockedByReconciledDay")); @@ -2456,10 +2457,13 @@ public async Task Unreconcile(int id) // inside the range) would be told "Dagen er låst op" while nothing // happened. var boundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); - if (boundary is null || planning.Date.Date != boundary.Value.Date) + if (boundary is null) { - return new OperationResult(false, - localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + return new OperationResult(false, localizationService.GetString("NothingIsReconciled")); + } + if (planning.Date.Date != boundary.Value.Date) + { + return OnlyLatestReconciledDayCanBeUnlocked(boundary.Value); } if (!planning.Reconciled) @@ -2471,15 +2475,15 @@ public async Task Unreconcile(int id) return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); } - catch (DayLockedException) + catch (DayLockedException e) { // Race: another request reconciled a newer day for this site // between the boundary read above and this save, so `planning`'s // day is no longer the boundary the interceptor will permit an // unlock on. Routine, not an incident -- no Sentry, same as - // Reconcile's DayLockedException catch. - return new OperationResult(false, - localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + // Reconcile's DayLockedException catch. The exception carries the + // new boundary, so naming it costs no query. + return OnlyLatestReconciledDayCanBeUnlocked(e.LockedThrough); } catch (Exception e) { @@ -2489,6 +2493,15 @@ public async Task Unreconcile(int id) } } + /// + /// The unlock refusal names the day to unlock first, so the user does not + /// have to hunt for it in the grid (spec §7). + /// + private OperationResult OnlyLatestReconciledDayCanBeUnlocked(DateTime boundary) + => new(false, localizationService.GetString( + "OnlyLatestReconciledDayCanBeUnlocked", + boundary.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture))); + public async Task> ReconcileThrough( ReconcileThroughRequestModel model) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index ec463a48..d882c906 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -35,6 +35,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.TimePlanningBase.Infrastructure.Data.Entities; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Data.Seed.Data; using Microting.TimePlanningBase.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.WorkingHours.UpdateCreate; @@ -467,9 +468,10 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr // crafted POST cannot write one. var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); // Locked rows are never even loaded, so nothing below can mutate - // one and have a later save flush it (ruling F15). The forward - // cascade walks this same list, so it skips them too. Not redundant - // with the loop's skip: only this keeps the cascade out of the lock. + // one and have a later save (which saves the whole context) flush + // it. The forward cascade walks this same list, so it skips them + // too. Not redundant with the loop's skip: only this keeps the + // cascade out of the lock. var planRegistrations = await dbContext.PlanRegistrations .Where(x => x.SdkSitId == model.SiteId) .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) @@ -637,7 +639,11 @@ await dbContext.PlanRegistrations.AsNoTracking() await planRegistration.Create(dbContext); } - catch (Exception e) + // A lock refusal passes through to CreateUpdate's catch. CreateUpdate + // skips locked days before calling this, so one only arrives when the + // boundary moved mid-request. Swallowed, the rejected Added entry + // stays tracked and the next save in the request fails on it anyway. + catch (Exception e) when (e is not DayLockedException) { SentrySdk.CaptureException(e); logger.LogError(e.Message); @@ -4011,6 +4017,8 @@ public async Task Import(IFormFile file) return new OperationResult(false, localizationService.GetString("FileFormatError")); } + // Observability only: the locked-day skip below is silent otherwise. + var lockedDaysSkipped = 0; foreach (Sheet sheet in sheets) { if (sheet.Name?.Value == null || sheet.Id?.Value == null) @@ -4096,6 +4104,7 @@ public async Task Import(IFormFile file) // tracked and no later save can flush a change into it. if (DayLockHelper.IsLocked(importLockedThrough, dateValue)) { + lockedDaysSkipped++; continue; } @@ -4187,6 +4196,8 @@ public async Task Import(IFormFile file) } } } + + Console.WriteLine($"[Import] summary: skipped {lockedDaysSkipped} locked day(s)."); } } } From 9808b4db335e6c55533139243b18407c7239377c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 18:04:53 +0200 Subject: [PATCH 15/18] docs(lock): bring the spec status in line with the implemented backend Status line, pinned base version, and section 13 now say what is built: the plugin write paths are guarded, unlock refusals name the day, the race window is documented, and the frontend must not ship before the service repo's PR is deployed. Co-Authored-By: Claude Opus 5 --- .../2026-09-12-reconciled-day-lock-design.md | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index 1196dd42..0a4c77dc 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -1,7 +1,7 @@ # Reconciled ("Afstemt") day lock — design **Date:** 2026-09-12 -**Status:** Design, awaiting review +**Status:** Accepted. Backend implemented (PR #1711); service repo, host styles and frontend follow. See §13 for the rulings taken during implementation. **Scope:** `eform-angular-timeplanning-plugin` (web UI + `TimePlanning.Pn` API), `eform-service-timeplanning-plugin` (background jobs). **No change to `eform-timeplanning-base`.** @@ -67,7 +67,7 @@ public DateTime? ReconciledAt { get; set; } // datetime(6) NULL ``` They are verified present in the currently pinned package -(`Microting.TimePlanningBase` **10.0.62**). Nothing writes them today; exactly +(`Microting.TimePlanningBase` **10.0.62**; implementation pinned 10.0.63, which still has them). Nothing writes them today; exactly one place reads them — the admin-only version diff in `TimePlanningPlanningService.CompareVersions`. @@ -508,10 +508,21 @@ wrong, in the SDD ledger. They supersede the sections they name. range, so §6.1's "localized failure" would make any range touching a reconciled day unsaveable. The save skips locked rows, and the page shows them read-only through its existing `IsLocked` flag. -- **More write paths to guard (F11, F12, F13). Ruled and briefed; not yet - implemented.** An audit found writes outside §5's list that reach locked - days: startup pause-id repair (unguarded, it would crash host startup), - Google Sheet pull, Excel import, the flex screen, absence approval and shift - handover, plus the service repo's sheet pull and flex catch-up. Task 5B - (plugin) and Task 7B (service repo) will make bulk re-syncs skip locked days - and give a user acting on specific days a message. +- **More write paths guarded (F11, F13 in the plugin; F12 in the service + repo).** An audit found writes outside §5's list that reach locked days. + In the plugin (PR #1711), each now skips locked days or answers with a + message: startup pause-id repair (unguarded, it would have crashed host + startup), Google Sheet pull, Excel import, the flex screen, and absence and + handover requests (checked when created, approved or accepted). In the + service repo (a separate PR), the sheet pull, the nightly recalculation and + the flex catch-up skip locked days. Bulk re-syncs skip; a user acting on + specific days gets a message. +- **Unlock refusals name the day to free first** (§7), and a worker with + nothing reconciled gets a distinct message. +- **The lock's race window is documented, not closed.** The boundary query + and the write are separate statements, so a reconcile committed between + them can let one write through. Every guarded path still saves through the + interceptor, which reads the boundary again. +- **Release order.** The frontend (PR4) must not reach production before the + service-repo PR is deployed; otherwise background jobs could still write + days the web shows as closed. From 07b598e6cd14d77fc79724586dff704d3ee2b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 05:23:58 +0200 Subject: [PATCH 16/18] feat(lock): restrict reconcile and unlock to admins Reconcile, unlock and bulk reconcile now require the admin role, matching how payroll export and the pay-rule endpoints are gated. Reading a day, editing an open one and the lock's own display are unchanged. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 60 +++++++++++++++++++ .../TimePlanningPlanningController.cs | 5 ++ 2 files changed, 65 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index e0c05603..9a08952a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; @@ -13,6 +15,7 @@ using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NSubstitute; using NUnit.Framework; +using TimePlanning.Pn.Controllers; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -1073,4 +1076,61 @@ public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLocked "or the client renders an entirely locked worker as fully editable"); }); } + + // --------------------------------------------------------------------- + // Server-side enforcement of "only an admin may reconcile or unlock a day" + // (product decision reversal — the spec's earlier "any web user may + // reconcile" no longer holds). A reflection test over the controller is + // the honest option here: the suite is service-level and never goes + // through the ASP.NET Core auth pipeline, so nothing else would catch a + // silently-dropped [Authorize] attribute. + // --------------------------------------------------------------------- + + [Test] + public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() + { + foreach (var methodName in new[] { "Reconcile", "Unreconcile", "ReconcileThrough" }) + { + var method = typeof(TimePlanningPlanningController).GetMethod(methodName); + Assert.That(method, Is.Not.Null, $"TimePlanningPlanningController.{methodName} must exist"); + + var authorizeAttributes = method! + .GetCustomAttributes(true) + .ToList(); + + Assert.That(authorizeAttributes, Is.Not.Empty, + $"{methodName} must carry an AuthorizeAttribute"); + + var roles = authorizeAttributes + .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) + .SelectMany(a => a.Roles!.Split(',')) + .Select(r => r.Trim()) + .ToList(); + + Assert.That( + roles.Any(r => string.Equals(r, EformRole.Admin, StringComparison.OrdinalIgnoreCase)), + Is.True, + $"{methodName} must be restricted to the '{EformRole.Admin}' role — found: " + + string.Join(", ", roles)); + } + } + + [Test] + public void Update_OpenDayAction_IsNotAdminRestricted() + { + // Reading and editing OPEN days is unchanged by the reconcile lock + // reversal — only reconcile/unreconcile/reconcile-through move behind + // the admin gate. Update is the named open-day action here. + var method = typeof(TimePlanningPlanningController).GetMethod("Update"); + Assert.That(method, Is.Not.Null, "TimePlanningPlanningController.Update must exist"); + + var roleRestricted = method! + .GetCustomAttributes(true) + .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) + .ToList(); + + Assert.That(roleRestricted, Is.Empty, + "Update (editing an open day) must not gain an admin-role restriction from the reconcile lock change — found: " + + string.Join(", ", roleRestricted.Select(a => a.Roles))); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index e1a55912..65dfdffb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -24,7 +24,9 @@ namespace TimePlanning.Pn.Controllers; using System.Collections.Generic; using System.Threading.Tasks; using Infrastructure.Models.Planning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Services.TimePlanningPlanningService; @@ -81,6 +83,7 @@ public async Task> GetV [HttpPut] [Route("{id}/reconcile")] + [Authorize(Roles = EformRole.Admin)] public async Task Reconcile(int id) { return await _planningService.Reconcile(id); @@ -88,6 +91,7 @@ public async Task Reconcile(int id) [HttpPut] [Route("{id}/unreconcile")] + [Authorize(Roles = EformRole.Admin)] public async Task Unreconcile(int id) { return await _planningService.Unreconcile(id); @@ -95,6 +99,7 @@ public async Task Unreconcile(int id) [HttpPut] [Route("reconcile-through")] + [Authorize(Roles = EformRole.Admin)] public async Task> ReconcileThrough( [FromBody] ReconcileThroughRequestModel model) { From 1eb5f77ee80f8a1ec74a8563b0d67f42b378a4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 05:46:21 +0200 Subject: [PATCH 17/18] fix(lock): gate reconcile and unlock by first user, not admin role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes 07b598e6 ("restrict reconcile and unlock to admins"), already pushed to this branch/PR #1711: the product decision changed again after that commit landed. It is not a role at all — only the first user (the account with the lowest AspNetUsers Id, per FirstUserHelper) may reconcile, unlock, or bulk-reconcile a day. The three controller actions now carry a bare [Authorize] (anonymous still refused by the pipeline); which signed-in caller may proceed is decided in TimePlanningPlanningService instead, since no [Authorize] role can express "the first user". Review follow-ups folded in: the first-user check now runs as the first statement inside each method's existing try (a DB round-trip above the try would have escaped the catch-all as a raw 500 with nothing in Sentry, instead of the OperationResult every other failure here produces); the refusal is extracted once into OnlyTheFirstUserCanReconcileOrUnlock(), mirroring the existing OnlyLatestReconciledDayCanBeUnlocked() precedent; the message key is renamed to match (it also covers unlock and bulk refusals, not just reconcile) with a trailing full stop in both languages, consistent with its resx neighbours; and the test suite gained the first-user behaviour tests (success, a different signed-in user refused with no write, and the userId-0-vs-empty-table edge case) alongside the restored open-day/no-role-restriction pins. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 151 +++++++++++++++--- .../TimePlanningPlanningController.cs | 11 +- .../Infrastructure/Helpers/FirstUserHelper.cs | 20 +++ .../Resources/Translations.Designer.cs | 6 + .../Resources/Translations.da.resx | 3 + .../Resources/Translations.resx | 3 + .../TimePlanningPlanningService.cs | 26 +++ 7 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index 9a08952a..58e5d2ec 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -68,6 +68,8 @@ public async Task SetUpTest() _userService = Substitute.For(); _userService.UserId.Returns(1); _userService.GetCurrentUserAsync().Returns(new EformUser { Id = 1 }); + // The fixture's default caller IS the first user; the gate tests override this. + _userService.GetFirstUserIdInDb().Returns(1); _localizationService = Substitute.For(); _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); @@ -162,6 +164,12 @@ private async Task BuildAdminIndexServiceAsync(Bas _userService.UserId.Returns(user.Id); _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); + // This admin user is also treated as the first user here: these tests + // exercise Index()'s recompute/lock-display behaviour, not the + // reconcile gate, and SeedReconciledBoundaryAsync/ + // SeedReconciledDayWithStaleStoredValuesAsync below reconcile THROUGH + // this same _userService substitute as a setup step. + _userService.GetFirstUserIdInDb().Returns(user.Id); _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); _indexLogger = Substitute.For>(); @@ -1078,16 +1086,115 @@ public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLocked } // --------------------------------------------------------------------- - // Server-side enforcement of "only an admin may reconcile or unlock a day" - // (product decision reversal — the spec's earlier "any web user may - // reconcile" no longer holds). A reflection test over the controller is - // the honest option here: the suite is service-level and never goes - // through the ASP.NET Core auth pipeline, so nothing else would catch a - // silently-dropped [Authorize] attribute. + // Server-side enforcement of "only the FIRST USER may reconcile or unlock + // a day" (corrected product decision — it is not a role at all, admin or + // otherwise). The gate lives in the SERVICE layer + // (TimePlanningPlanningService.Reconcile/Unreconcile/ReconcileThrough, + // via FirstUserHelper.IsFirstUserAsync), because that is the only path to + // these writes (see the controller, which now carries a bare + // [Authorize] — anonymous is still refused by the pipeline, but which + // signed-in caller may proceed is decided here). These are therefore + // proper behaviour tests, not reflection: success/refusal and, on + // refusal, that nothing was written. // --------------------------------------------------------------------- [Test] - public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() + public async Task Reconcile_TheFirstUser_Succeeds() + { + // Deliberately explicit, not redundant with SeedReconciledBoundaryAsync's + // internal assert: on a gate feature, the ALLOWED case deserves a pin a + // reader can find by name, not one inferred from a fixture's internals. + var row = await SeedPlain(940, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + } + + [Test] + public async Task Reconcile_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var row = await SeedPlain(941, DateTime.Now.Date.AddDays(-5)); + // Someone else is signed in; user 1 remains the first user. + _userService.UserId.Returns(2); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.False, "a refused caller must not reconcile the day"); + Assert.That(reloaded.ReconciledAt, Is.Null); + }); + } + + [Test] + public async Task Reconcile_CallerWithNoUserId_IsRefused_EvenWhenTheUsersTableIsEmpty() + { + // UserId 0 (no signed-in user) paired with GetFirstUserIdInDb also + // answering 0 (an empty users table) must NOT satisfy 0 == 0 -- the + // house rule's whole point (FirstUserHelper.IsFirstUserAsync). + var row = await SeedPlain(942, DateTime.Now.Date.AddDays(-5)); + _userService.UserId.Returns(0); + _userService.GetFirstUserIdInDb().Returns(0); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.That(reloaded.Reconciled, Is.False, + "userId 0 must never pass, even against an empty/zero first-user id"); + } + + [Test] + public async Task Unreconcile_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var boundary = await SeedReconciledBoundaryAsync(943, DateTime.Now.Date.AddDays(-3)); + _userService.UserId.Returns(2); + + var result = await _service.Unreconcile(boundary.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.True, "a refused caller must not unlock the day"); + } + + [Test] + public async Task ReconcileThrough_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var row = await SeedPlain(944, DateTime.Now.Date.AddDays(-6)); + _userService.UserId.Returns(2); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 944 } + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.That(reloaded.Reconciled, Is.False, "a refused caller must not bulk-reconcile anything"); + } + + /// + /// Cheap and still worth pinning: anonymous access must stay blocked even + /// though the role check is gone -- these three keep a bare [Authorize] -- + /// AND that none of them carry a role restriction any more, since the + /// mechanism really changed to the service-layer first-user check, not + /// merely gained one. The real control is the behaviour tests above; this + /// only guards against either half of that shape drifting silently. + /// + [Test] + public void Reconcile_Unreconcile_ReconcileThrough_RequireAuthorize_ButCarryNoRole() { foreach (var methodName in new[] { "Reconcile", "Unreconcile", "ReconcileThrough" }) { @@ -1099,28 +1206,24 @@ public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() .ToList(); Assert.That(authorizeAttributes, Is.Not.Empty, - $"{methodName} must carry an AuthorizeAttribute"); + $"{methodName} must carry an AuthorizeAttribute so anonymous callers are refused"); - var roles = authorizeAttributes - .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) - .SelectMany(a => a.Roles!.Split(',')) - .Select(r => r.Trim()) - .ToList(); - - Assert.That( - roles.Any(r => string.Equals(r, EformRole.Admin, StringComparison.OrdinalIgnoreCase)), - Is.True, - $"{methodName} must be restricted to the '{EformRole.Admin}' role — found: " + - string.Join(", ", roles)); + var roleRestricted = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles)).ToList(); + Assert.That(roleRestricted, Is.Empty, + $"{methodName} must NOT be role-restricted -- the first-user check lives in the service, " + + "not a role -- found: " + string.Join(", ", roleRestricted.Select(a => a.Roles))); } } + /// + /// Reading and editing OPEN days is unrelated to the reconcile gate, + /// whichever mechanism guards reconcile itself (admin role, then first + /// user). Update is the named open-day action here, and this assertion + /// stands on its own regardless of which gate reconcile currently uses. + /// [Test] - public void Update_OpenDayAction_IsNotAdminRestricted() + public void Update_OpenDayAction_NeverAcquiresARoleRestriction() { - // Reading and editing OPEN days is unchanged by the reconcile lock - // reversal — only reconcile/unreconcile/reconcile-through move behind - // the admin gate. Update is the named open-day action here. var method = typeof(TimePlanningPlanningController).GetMethod("Update"); Assert.That(method, Is.Not.Null, "TimePlanningPlanningController.Update must exist"); @@ -1130,7 +1233,7 @@ public void Update_OpenDayAction_IsNotAdminRestricted() .ToList(); Assert.That(roleRestricted, Is.Empty, - "Update (editing an open day) must not gain an admin-role restriction from the reconcile lock change — found: " + + "Update (editing an open day) must not gain a role restriction from the reconcile gate — found: " + string.Join(", ", roleRestricted.Select(a => a.Roles))); } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index 65dfdffb..4d9b0fe8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -26,7 +26,6 @@ namespace TimePlanning.Pn.Controllers; using Infrastructure.Models.Planning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Services.TimePlanningPlanningService; @@ -81,9 +80,13 @@ public async Task> GetV return await _planningService.GetVersionHistory(planRegistrationId); } + // Anonymous callers are still refused by the pipeline, but WHICH signed-in + // caller may proceed is no longer a role — it is decided in the service + // layer (TimePlanningPlanningService.Reconcile/Unreconcile/ReconcileThrough), + // which is the only path to these writes. See FirstUserHelper.IsFirstUserAsync. [HttpPut] [Route("{id}/reconcile")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task Reconcile(int id) { return await _planningService.Reconcile(id); @@ -91,7 +94,7 @@ public async Task Reconcile(int id) [HttpPut] [Route("{id}/unreconcile")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task Unreconcile(int id) { return await _planningService.Unreconcile(id); @@ -99,7 +102,7 @@ public async Task Unreconcile(int id) [HttpPut] [Route("reconcile-through")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task> ReconcileThrough( [FromBody] ReconcileThroughRequestModel model) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs new file mode 100644 index 00000000..7a90d3af --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microting.eFormApi.BasePn.Abstractions; + +namespace TimePlanning.Pn.Infrastructure.Helpers; + +/// +/// Mirrors eFormAPI.Web's Infrastructure.Helpers.FirstUserHelper: the first +/// user is the account with the lowest AspNetUsers Id. A caller without a +/// user id is never the first user, even when the users table is empty (in +/// which case GetFirstUserIdInDb would also answer 0, and 0 == 0 must not +/// mean "everyone is the first user"). +/// +public static class FirstUserHelper +{ + public static async Task IsFirstUserAsync(this IUserService userService) + { + var userId = userService.UserId; + return userId > 0 && userId == await userService.GetFirstUserIdInDb(); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index 004dd543..c137ec3c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -327,6 +327,12 @@ internal static string CannotReconcileTodayOrFuture { } } + internal static string OnlyTheFirstUserCanReconcileOrUnlock { + get { + return ResourceManager.GetString("OnlyTheFirstUserCanReconcileOrUnlock", resourceCulture); + } + } + internal static string OnlyLatestReconciledDayCanBeUnlocked { get { return ResourceManager.GetString("OnlyLatestReconciledDayCanBeUnlocked", resourceCulture); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index 9bb92427..0af50400 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -210,6 +210,9 @@ Kun dage før i dag kan afstemmes. + + Kun den første bruger kan afstemme og låse dage op. + Lås den seneste afstemte dag op først ({0}). diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index f1acdabb..ebaf692a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -210,6 +210,9 @@ Only days before today can be reconciled. + + Only the first user can reconcile or unlock days. + Unlock the most recent reconciled day first ({0}). 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 5bc39273..bc12b167 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -2387,6 +2387,11 @@ public async Task Reconcile(int id) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, OnlyTheFirstUserCanReconcileOrUnlock()); + } + var planning = await FindActivePlanningAsync(id); if (planning == null) @@ -2446,6 +2451,11 @@ public async Task Unreconcile(int id) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, OnlyTheFirstUserCanReconcileOrUnlock()); + } + var planning = await FindActivePlanningAsync(id); if (planning == null) @@ -2502,11 +2512,27 @@ private OperationResult OnlyLatestReconciledDayCanBeUnlocked(DateTime boundary) "OnlyLatestReconciledDayCanBeUnlocked", boundary.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture))); + /// + /// No [Authorize] role can express "the first user" -- it names a single, + /// data-dependent account (the lowest AspNetUsers Id), not a role -- so + /// Reconcile, Unreconcile and ReconcileThrough each check + /// FirstUserHelper.IsFirstUserAsync in the service layer instead, and + /// share this refusal message. + /// + private string OnlyTheFirstUserCanReconcileOrUnlock() + => localizationService.GetString("OnlyTheFirstUserCanReconcileOrUnlock"); + public async Task> ReconcileThrough( ReconcileThroughRequestModel model) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationDataResult(false, + OnlyTheFirstUserCanReconcileOrUnlock()); + } + if (model == null || model.SiteIds.Count == 0) { return new OperationDataResult(false, From d43110e6299a30be1c523d46a53f4281082ffde0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 06:06:51 +0200 Subject: [PATCH 18/18] docs(lock): record the first-user restriction in the spec and plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec and plan still asserted "any web user may reconcile" / "no admin gate", the opposite of what commits 07b598e6 and 1eb5f77e actually built. Updates §8.6 Permissions (the reversal, why it is a service-layer check and not an [Authorize] role, that the UI hides rather than disables the three controls, and that lock display stays visible to everyone regardless), adds a §13 revision bullet recording when and why the decision reversed a second time and the release-order consequence for PR1/PR4, and points the plan's Global Constraints and Spec coverage lines at §8.6 instead of restating the mechanism. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-12-reconciled-day-lock.md | 4 +-- .../2026-09-12-reconciled-day-lock-design.md | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md index 56625d34..8c023bde 100644 --- a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md @@ -27,7 +27,7 @@ - **I2 is load-bearing beyond its own purpose.** Because the boundary is always in the past and edits are only allowed above it, forward flex cascades (one runs 180 days ahead, one is unbounded) provably cannot reach a locked day. If I2 is ever relaxed, those cascades must be revisited first. - **Timezone:** compare against `DateTime.Now.Date` (server local), matching `PlanRegistrationHelper` and the existing mobile guard. Never `UtcNow`. - **Copy rule:** user-facing text states what the day *is*. It never explains a restriction by referring to what an administrator may do. ("admin = Microting".) -- **Permissions:** any web user may reconcile. No admin gate on reconcile or unlock. +- **Permissions:** only the first user may reconcile, unlock, or bulk-reconcile — see spec §8.6. - **Payroll:** `Reconciled` and `TransferredToPayroll` are independent in both directions. Do not couple them. - **Mobile:** rejects the write with the same localized failure as web. No mobile UI work. - **Tests run only in CI.** Never run `dotnet test`, `playwright test`, `jest` or `npm test` locally — a hook blocks them. `dotnet build` is allowed and expected. Push and watch `gh pr checks `. @@ -2518,7 +2518,7 @@ Re-running a failed job **overwrites** its conclusion, so a green run can hide a ## Self-Review -**Spec coverage.** §4 data model → Task 1. §4.2 I1 → Tasks 4 (write) and 1 (test). I2 → Tasks 1, 4, 12. I3 → Task 2. §4.3 query cost → Task 1 (`LockedThroughForSitesAsync`). §5 write inventory → Tasks 2, 5, 7. §6.1 three layers → Tasks 2 (L1), 5 (L2, L3). §6.2 cascades → Global Constraints + Task 1 doc comment. §6.3 gap-fill → Task 5 Step 5. §6.4 timezone → Task 1 `CanReconcile`. §7 API → Task 4; read model → Task 6. §8.1 three states → Tasks 9, 10. §8.2 single day → Task 11. §8.3 bulk → Tasks 4 (`ReconcileThrough`), 12. §8.4 unlock → Tasks 4, 11. §8.5 blocked feedback → Task 11. §8.6 permissions → no admin gate anywhere (verified: no `[Authorize]` added in Task 4). §9 testing → Tasks 1, 2, 4, 5, 6, 14. +**Spec coverage.** §4 data model → Task 1. §4.2 I1 → Tasks 4 (write) and 1 (test). I2 → Tasks 1, 4, 12. I3 → Task 2. §4.3 query cost → Task 1 (`LockedThroughForSitesAsync`). §5 write inventory → Tasks 2, 5, 7. §6.1 three layers → Tasks 2 (L1), 5 (L2, L3). §6.2 cascades → Global Constraints + Task 1 doc comment. §6.3 gap-fill → Task 5 Step 5. §6.4 timezone → Task 1 `CanReconcile`. §7 API → Task 4; read model → Task 6. §8.1 three states → Tasks 9, 10. §8.2 single day → Task 11. §8.3 bulk → Tasks 4 (`ReconcileThrough`), 12. §8.4 unlock → Tasks 4, 11. §8.5 blocked feedback → Task 11. §8.6 permissions → first-user-only, enforced in the service layer, not a controller role (see spec §8.6; verified by `ReconcileServiceTests`'s first-user behaviour tests). §9 testing → Tasks 1, 2, 4, 5, 6, 14. **Gaps found and closed while reviewing:** - The interceptor must permit the unlock write on the boundary day, or unlocking would be blocked by the lock it removes. Added `IsUnlockOfBoundaryDay` (Task 2 Step 3) and a test for it. diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index 0a4c77dc..eb52cc5a 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -408,11 +408,18 @@ is a standing constraint in this product.) ### 8.6 Permissions -Per explicit decision: **any web user may reconcile.** This is a deliberate -departure from the rest of the toolbar — payroll export, for instance, is -admin-gated — and it means an ordinary user can freeze a period. The -reverse-order unlock rule is the only safeguard, and it is the reason unlock -carries the heavier confirmation. +Supersedes the original decision recorded here (any web user may reconcile) +at the user's request — see §13; not a silent rewrite. **Only the first +user — the account with the lowest `AspNetUsers` Id — may reconcile, unlock, +or bulk-reconcile.** The server is the authority: no `[Authorize]` role can +express "the first user", so the rule is enforced as a service-layer check +in `TimePlanningPlanningService`, not a controller attribute. The UI hides +the three controls from every other user rather than disabling them with an +explanation. Lock **display** is unaffected by who may act on it and stays +visible to every user regardless: hatching, seals, glyphs, tooltips, the +legend, the read-only dialog, the provenance line, and the free-first line +on unlock. The reverse-order unlock rule is still the reason unlock carries +the heavier confirmation. --- @@ -526,3 +533,12 @@ wrong, in the SDD ledger. They supersede the sections they name. - **Release order.** The frontend (PR4) must not reach production before the service-repo PR is deployed; otherwise background jobs could still write days the web shows as closed. +- **§8.6 permissions reversed to first-user-only, after the feature was + complete and CI-green.** Not an admin role either: only the first user + (lowest `AspNetUsers` Id) may reconcile, unlock, or bulk-reconcile, + enforced as a service-layer check because no `[Authorize]` role can + express it. The admin-role version (commit `07b598e6`) was superseded by a + follow-up commit (`1eb5f77e`) rather than rewritten, since `07b598e6` was + already pushed and CI-green. Release consequence: between PR1 and PR4 + merging, every user sees reconcile controls that refuse, so the two must + merge as one sequence, not independently.