diff --git a/docs/superpowers/plans/2026-06-10-per-worker-paycode-columns.md b/docs/superpowers/plans/2026-06-10-per-worker-paycode-columns.md new file mode 100644 index 00000000..d52fee6d --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-per-worker-paycode-columns.md @@ -0,0 +1,318 @@ +# Per-Worker Pay-Code Columns 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:** In the all-workers Excel export, make each per-site (per-worker) sheet show only the pay-code columns declared in that worker's own pay-rule-set (none if the worker has no rule-set), leaving the Total summary sheet and the single-worker export unchanged. + +**Architecture:** Add a pure `GetDeclaredPayCodes(PayRuleSet)` helper that returns a rule-set's declared pay codes. In the all-workers per-site loop, compute `sitePayCodes` from the cached per-site `PayRuleSet` and use it (instead of the global `allPayCodes`) for the per-site sheet's pay-code header, per-day cells, and totals row. The global `allPayCodes` and `siteTotalsByPayCode` stay as-is so the Total sheet is byte-identical. + +**Tech Stack:** C# / .NET 10, DocumentFormat.OpenXml, NUnit 4 + Testcontainers.MariaDb. Base dev mode: edit in host-app mirror `eform-angular-frontend/eFormAPI/Plugins/TimePlanning.Pn/`, then `devgetchanges.sh` to the source repo. + +**Spec:** `docs/superpowers/specs/2026-06-10-per-worker-paycode-columns-design.md` + +**Key file:** `…/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` + +**Verified facts:** +- All-workers per-site loop emits pay codes at 3 points, all using global `allPayCodes`: + header (~line 3194), per-day cells (~3267), per-site totals row (~3346). +- `siteTotalsByPayCode = allPayCodes.ToDictionary(p => p, p => 0.0)` (~3252) is accumulated + (~3287–3290, guarded by `ContainsKey`) and consumed by BOTH the per-site totals row AND the + Total-sheet row (~3403). It must stay keyed by `allPayCodes` for the Total sheet. +- The per-site pay-code header (~3194) is built BEFORE `perSiteCache.TryGetValue(... out var cache)` + (~3258). So `sitePayCodes` must be computed before the header loop (use a separate lookup var to + avoid touching the existing `cache` declaration). +- `AutoFilter` uses `GetColumnLetter(headerStrings.Count)` (~3353) — auto-adjusts to the per-site + column count once the header uses `sitePayCodes`. No change needed there. +- `PayRuleSet` entity (eform-timeplanning-base): `DayRules: ICollection`, + `DayTypeRules: ICollection`, `HolidayPaidOffPayCode: string`. + `PayDayRule.Tiers: ICollection`; `PayTierRule.PayCode: string`, `.Order: int`. + `PayDayTypeRule.DefaultPayCode: string`, `.TimeBandRules: ICollection`; + `PayTimeBandRule.PayCode: string`. +- `CalculatePayLinesForDay` is `internal static` (~line 4168); `MergeByPayCode` static (~3922). + `InternalsVisibleTo("TimePlanning.Pn.Test")` is configured, so `internal static` helpers are + unit-testable directly without a service instance. +- `CalculatePayLinesForDayTests.cs` is NOT in any CI shard — do NOT add CI-critical tests there. + `PlanRegistrationHelperTests.cs` is in shard **f** and already hosts static-helper tests + (GetShiftTime). `DagsoversigtWorksheetExportTests.cs` is in shard **g** and exercises the + all-workers export. + +--- + +## Task 1: Add `GetDeclaredPayCodes` (TDD) + +**Files:** +- Modify: `…/TimePlanningWorkingHoursService.cs` (add static helper near `MergeByPayCode`, ~line 3922) +- Test: `…/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs` + +- [ ] **Step 1: Write the failing unit tests** + +Add to `PlanRegistrationHelperTests.cs` (inside the top-level `[TestFixture]`). The helper is +`internal static`, so call it directly on the type — no instance/DB needed. Match the file's +existing NUnit 4 style (`using` for the entities may be needed — `Microting.TimePlanningBase.Infrastructure.Data.Entities`). + +```csharp +[Test] +public void GetDeclaredPayCodes_CollectsFromAllSources_DedupsAndSkipsEmpty_OrdersTiersByOrder() +{ + var ruleSet = new PayRuleSet + { + DayRules = new List + { + new PayDayRule { Tiers = new List + { + new PayTierRule { PayCode = "B", Order = 2 }, + new PayTierRule { PayCode = "A", Order = 1 }, + }}, + new PayDayRule { Tiers = new List + { + new PayTierRule { PayCode = "A", Order = 1 }, // duplicate + }}, + }, + DayTypeRules = new List + { + new PayDayTypeRule { DefaultPayCode = "C", TimeBandRules = new List + { + new PayTimeBandRule { PayCode = "D" }, + new PayTimeBandRule { PayCode = "" }, // skipped + }}, + new PayDayTypeRule { DefaultPayCode = null, TimeBandRules = new List() }, // skipped + }, + HolidayPaidOffPayCode = "E", + }; + + var codes = TimePlanningWorkingHoursService.GetDeclaredPayCodes(ruleSet); + + // tiers ordered by Order (A before B) within the first day rule, then C, D, then holiday E; + // duplicate A and empty/null skipped. + Assert.That(codes, Is.EqualTo(new List { "A", "B", "C", "D", "E" })); +} + +[Test] +public void GetDeclaredPayCodes_NullRuleSet_ReturnsEmpty() +{ + Assert.That(TimePlanningWorkingHoursService.GetDeclaredPayCodes(null), Is.Empty); +} + +[Test] +public void GetDeclaredPayCodes_EmptyRuleSet_ReturnsEmpty() +{ + Assert.That(TimePlanningWorkingHoursService.GetDeclaredPayCodes(new PayRuleSet()), Is.Empty); +} +``` + +- [ ] **Step 2: Run the tests to verify they FAIL** + +Run: `cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eFormAPI && dotnet test Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanning.Pn.Test.csproj --filter GetDeclaredPayCodes` +Expected: FAIL — `GetDeclaredPayCodes` does not exist (compile error). (These tests are pure/in-memory; if the fixture needs a DB container that can't start here, report DONE_WITH_CONCERNS — CI shard f runs them.) + +- [ ] **Step 3: Implement `GetDeclaredPayCodes`** + +Add near `MergeByPayCode` (~line 3922) in `TimePlanningWorkingHoursService.cs`: + +```csharp + /// + /// Returns the pay codes DECLARED by a pay-rule-set, in structural order + /// (day-rule tiers by Order, then day-type default codes and their time-band codes, + /// then the holiday code), de-duplicated (first-seen wins), skipping null/empty codes. + /// Returns an empty list when payRuleSet is null. Used to scope per-worker pay-code + /// columns in the all-workers export to each worker's own rule-set. + /// + internal static List GetDeclaredPayCodes(PayRuleSet payRuleSet) + { + var codes = new List(); + if (payRuleSet == null) + { + return codes; + } + + void Add(string code) + { + if (!string.IsNullOrWhiteSpace(code) && !codes.Contains(code)) + { + codes.Add(code); + } + } + + if (payRuleSet.DayRules != null) + { + foreach (var dayRule in payRuleSet.DayRules) + { + if (dayRule.Tiers == null) continue; + foreach (var tier in dayRule.Tiers.OrderBy(t => t.Order)) + { + Add(tier.PayCode); + } + } + } + + if (payRuleSet.DayTypeRules != null) + { + foreach (var dayTypeRule in payRuleSet.DayTypeRules) + { + Add(dayTypeRule.DefaultPayCode); + if (dayTypeRule.TimeBandRules == null) continue; + foreach (var timeBand in dayTypeRule.TimeBandRules) + { + Add(timeBand.PayCode); + } + } + } + + Add(payRuleSet.HolidayPaidOffPayCode); + + return codes; + } +``` + +(`System.Linq` and the `PayRuleSet` type are already in scope in this file.) + +- [ ] **Step 4: Run the tests to verify they PASS** + +Run: `cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eFormAPI && dotnet test Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanning.Pn.Test.csproj --filter GetDeclaredPayCodes` +Expected: all 3 pass. + +- [ ] **Step 5: Do not commit** (base dev mode — commit once at the end, Task 4). + +--- + +## Task 2: Use per-site codes in the all-workers per-site sheet + +**Files:** +- Modify: `…/TimePlanningWorkingHoursService.cs` — all-workers `GenerateExcelDashboard(TimePlanningWorkingHoursReportForAllWorkersRequestModel)` per-site loop. + +Read the per-site loop first to anchor on exact current text (the three `foreach (var payCode in allPayCodes)` blocks and the header). Make these four edits. Do NOT touch the global `allPayCodes` pre-pass, `siteTotalsByPayCode`'s seed/accumulation, the Total-sheet header/row, or the single-worker export. + +- [ ] **Step 1: Compute `sitePayCodes` before the per-site pay-code header** + +Find the per-site header pay-code loop: +```csharp + // Append one column header per pay code discovered across all sites + foreach (var payCode in allPayCodes) + { + headerStrings.Add(payCode); + } +``` +Replace it with (compute `sitePayCodes` from the cached per-site rule-set, then use it): +```csharp + // Per-worker pay-code columns: only the codes declared in THIS site's + // pay-rule-set (empty when the site has no rule-set). The global allPayCodes + // is still used for the Total sheet, which is intentionally unchanged. + perSiteCache.TryGetValue(siteIds[i], out var siteCacheForCodes); + var sitePayCodes = GetDeclaredPayCodes(siteCacheForCodes?.PayRuleSet); + foreach (var payCode in sitePayCodes) + { + headerStrings.Add(payCode); + } +``` +(`siteCacheForCodes` is a new local distinct from the existing `cache` declared later — no conflict.) + +- [ ] **Step 2: Per-day pay-code cells use `sitePayCodes`** + +Find: +```csharp + foreach (var payCode in allPayCodes) + { + var payLine = dayPayLines.FirstOrDefault(pl => pl.PayCode == payCode); + dataRow.Append(CreateNumericCell(payLine?.Hours ?? 0)); + } +``` +Change the loop source to `sitePayCodes`: +```csharp + foreach (var payCode in sitePayCodes) + { + var payLine = dayPayLines.FirstOrDefault(pl => pl.PayCode == payCode); + dataRow.Append(CreateNumericCell(payLine?.Hours ?? 0)); + } +``` +Leave the `siteTotalsByPayCode` accumulation block (the `foreach (var pl in dayPayLines) { if (siteTotalsByPayCode.ContainsKey(pl.PayCode)) ... }`) UNCHANGED — it feeds the Total sheet. + +- [ ] **Step 3: Per-site totals row uses `sitePayCodes` (safe lookup)** + +Find: +```csharp + foreach (var payCode in allPayCodes) + { + siteTotalsRow.Append(CreateNumericCell(siteTotalsByPayCode[payCode])); + } +``` +Change to iterate `sitePayCodes` and read totals safely (a declared code with 0 hours everywhere is not a key in `siteTotalsByPayCode`): +```csharp + foreach (var payCode in sitePayCodes) + { + siteTotalsRow.Append(CreateNumericCell(siteTotalsByPayCode.GetValueOrDefault(payCode, 0))); + } +``` + +- [ ] **Step 4: Confirm the column counts stay aligned & build** + +The per-site header, each data row, and the totals row now all iterate `sitePayCodes` → equal pay-code cell counts. `AutoFilter` uses `headerStrings.Count` so it auto-adjusts. Build: + +Run: `cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eFormAPI && dotnet build Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj` +Expected: Build succeeded. + +- [ ] **Step 5: Do not commit.** + +--- + +## Task 3: Export regression/coverage test (different rule-sets per worker) + +**Files:** +- Modify: `…/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs` (shard g; exercises the all-workers export) + +Read the file's `SeedSiteAndPlanRegistration`/seed helpers and `TestBaseSetup` first to learn how sites, assigned sites, and (if present) pay-rule-sets are created. Look at `PayRuleSetServiceTests.cs` for how a `PayRuleSet` with `DayRules`/`Tiers` is constructed/persisted, and reuse that pattern to seed rule-sets in the plugin DbContext. + +- [ ] **Step 1: Write the test** + +Add `[Test] AllWorkers_PerSiteSheets_UseEachWorkersOwnPayRuleSetCodes` that: +- Seeds **site A** with an `AssignedSite.PayRuleSetId` → a `PayRuleSet` whose declared codes are e.g. `{ "AAA" }` (one `PayDayRule` with one `PayTierRule { PayCode = "AAA" }`), plus a PlanRegistration in the period. +- Seeds **site B** with a different `PayRuleSet` declaring `{ "BBB" }`, plus a PlanRegistration. +- Seeds **site C** with NO `PayRuleSetId`, plus a PlanRegistration. +- Calls the all-workers export `GenerateExcelDashboard(new TimePlanningWorkingHoursReportForAllWorkersRequestModel { DateFrom, DateTo })`. +- Opens the produced xlsx and, for each per-site sheet (named `site.Name`, i.e. "Site A"/"Site B"/"Site C"), reads the header row and asserts: + - site A's sheet header contains `"AAA"` and NOT `"BBB"`. + - site B's sheet header contains `"BBB"` and NOT `"AAA"`. + - site C's sheet header contains neither `"AAA"` nor `"BBB"` (no pay-code columns). + - the **Total** sheet header still contains BOTH `"AAA"` and `"BBB"` (union unchanged). + +Read sheet headers by resolving each `Sheet` by name via the workbook `Sheets` and `GetPartById`, then reading row 1 cells (reuse/extend the file's existing header-reading helper; pay-code headers are plain string cells appended after the fixed columns). Dispose each returned stream as the existing tests do (the export returns an open FileStream; the file-collision pattern from the prior fix applies if multiple exports run in the same second — call the all-workers export once here). + +- [ ] **Step 2: Build + run** + +Run: `cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eFormAPI && dotnet test Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanning.Pn.Test.csproj --filter AllWorkers_PerSiteSheets_UseEachWorkersOwnPayRuleSetCodes` +Expected: PASS (with Task 2 applied). Needs Docker/Testcontainers; if unavailable here, report DONE_WITH_CONCERNS with the test written — CI shard g runs it. + +- [ ] **Step 3: Do not commit.** + +> If seeding a full `PayRuleSet` graph in the fixture proves disproportionately complex (e.g. the +> seed helpers don't support assigned-site → rule-set linkage and constructing it inline is +> heavy), STOP and report NEEDS_CONTEXT with what you found, rather than writing a test that +> doesn't actually exercise per-site codes. The Task 1 unit test + the manual verification in +> Task 4 still cover correctness; this integration test is the ideal but not worth a brittle hack. + +--- + +## Task 4: Verify, code-review, sync, PR, CI + +- [ ] **Step 1: Full build + targeted tests** + +Run: `cd /home/rene/Documents/workspace/microting/eform-angular-frontend/eFormAPI && dotnet build Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj && dotnet test Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanning.Pn.Test.csproj --filter "GetDeclaredPayCodes|AllWorkers_PerSiteSheets_UseEachWorkersOwnPayRuleSetCodes"` +Expected: build succeeds; the new tests pass. + +- [ ] **Step 2: Code review** — use `superpowers:requesting-code-review` on the diff (service helper + per-site wiring + tests). Confirm the Total sheet and single-worker export are untouched. + +- [ ] **Step 3: Sync to source repo** + +From `/home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin`: create branch `fix/per-worker-paycode-columns` off `stable`, run `./devgetchanges.sh`, then `git checkout -- '*.csproj' '*.conf.ts' '*.xlsx' '*.docx'`, then `git status`. Confirm only intended files changed: `…/TimePlanningWorkingHoursService.cs`, `…/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs`, `…/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs`, plus the spec/plan docs. `git checkout` anything unintended. + +- [ ] **Step 4: Commit + push + PR + watch CI** + +Stage only the intended files by name, commit (end message with `Co-Authored-By: Claude Opus 4.8 `), push, open a PR toward `stable`, and watch CI green. The unit test runs in shard **f** (`PlanRegistrationHelperTests`) and the export test in shard **g** (`DagsoversigtWorksheetExportTests`) — both already in the matrix, so no workflow change is needed. Treat any known-flaky playwright shard failures as flaky (re-run); fix genuine failures. + +--- + +## Self-Review + +- **Spec coverage:** per-site sheets use declared per-site codes (Task 2 Steps 1–3) ✓; declared-code helper from all four sources, dedup, order, null→empty (Task 1) ✓; no rule-set → no columns (`GetDeclaredPayCodes(null)`→empty drives empty header — Task 1 + Task 2 Step 1) ✓; Total sheet unchanged (`allPayCodes`/`siteTotalsByPayCode` untouched; per-site totals row reads via `GetValueOrDefault` — Task 2 Steps 2–3) ✓; single-worker untouched ✓; tests (unit + export E2E) ✓. +- **Placeholder scan:** none — concrete code/commands throughout. The Task 3 fallback is an explicit escalation condition, not a vague placeholder. +- **Type/name consistency:** `GetDeclaredPayCodes(PayRuleSet) : List` (internal static) defined in Task 1, called identically in Task 2 Step 1 and the Task 1 tests; `sitePayCodes`/`siteCacheForCodes` locals introduced in Task 2 Step 1 and reused in Steps 2–3; `siteTotalsByPayCode.GetValueOrDefault(payCode, 0)` matches the existing `Dictionary` type. +- **Total-sheet safety:** the only read of `siteTotalsByPayCode` that changes is the per-site totals row (now `sitePayCodes` + `GetValueOrDefault`); the Total-sheet row (`foreach allPayCodes … siteTotalsByPayCode[payCode]`) and the seed/accumulation are untouched, so the Total sheet stays byte-identical. diff --git a/docs/superpowers/specs/2026-06-10-per-worker-paycode-columns-design.md b/docs/superpowers/specs/2026-06-10-per-worker-paycode-columns-design.md new file mode 100644 index 00000000..dfb3ae75 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-per-worker-paycode-columns-design.md @@ -0,0 +1,165 @@ +# Design: Per-worker pay-code columns in the all-workers Excel export + +**Date:** 2026-06-10 +**Plugin:** eform-angular-timeplanning-plugin (`TimePlanning.Pn`) +**Dev mode:** Base dev mode — edit in the `eform-angular-frontend` host-app mirror, then +`devgetchanges.sh` back to the source repo for committing. + +## Summary + +In the **all-workers** Excel export, each per-site (per-worker) sheet currently shows pay-code +columns for the **global union** of pay codes across *all* workers — so whenever any worker has +an active pay-rule-set, every worker's sheet gets every pay code (filled `0` for codes that +aren't theirs). Fix this so each per-site sheet shows only the pay codes **declared in that +worker's own pay-rule-set**. A worker with no active pay-rule-set gets **no** pay-code columns. + +The **Total** summary sheet is **completely unchanged** (it keeps the global-union columns, +regardless of which pay-rule-sets are active). The single-worker export is already correct and +is untouched. + +## Background (current behaviour — verified) + +In `GenerateExcelDashboard(TimePlanningWorkingHoursReportForAllWorkersRequestModel)` +(`Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs`): + +- A pre-pass loop (~lines 2901–2971) builds a single `List allPayCodes` = the union of + pay codes that appeared in the **computed** pay lines across **all** sites. It also caches per + site, in `AllWorkersSiteCache` (`PayRuleSet`, `PayLinesByDate`, …). +- `allPayCodes` is then used as the pay-code column set in **four** places: + - **Total** sheet header (~3061–3065) and **Total** row fill (~3402–3406). *(Total sheet)* + - **Per-site** sheet header (~3193–3197), per-day cell fill (~3267–3271), per-site totals row + (~3346–3349). *(Per-site sheets — the bug)* +- `siteTotalsByPayCode` (seeded from `allPayCodes` at ~3252) is accumulated per site and consumed + by **both** the per-site totals row and the Total-sheet row. + +The single-worker `GenerateExcelDashboard(TimePlanningWorkingHoursRequestModel)` (~2390–2419) +already scopes its `allPayCodes` to the one site — it is the correct model and is not changed. + +Pay codes are plain strings. A `PayRuleSet` declares its codes via: +`DayRules[].Tiers[].PayCode`, `DayTypeRules[].DefaultPayCode`, +`DayTypeRules[].TimeBandRules[].PayCode`, and `HolidayPaidOffPayCode` +(all in `eform-timeplanning-base/.../Entities/`). The all-workers pre-pass already +`.Include`s `DayRules.Tiers` and `DayTypeRules.TimeBandRules` when loading each site's +`PayRuleSet`, so the declared codes are available on the cached entity without extra queries. + +## Decisions (from brainstorming) + +| # | Decision | +|---|----------| +| 1 | Change **per-site sheets only**. The **Total sheet is completely unchanged** (keeps the global union, regardless of active pay-rule-sets). | +| 2 | Per-site columns = the pay codes **declared** in that worker's pay-rule-set (not just codes that had hours this period). | +| 3 | A worker with **no active pay-rule-set** → **no** pay-code columns on their sheet. | +| 4 | Column **order** = the rule-set's structural order: day-rule tiers → day-type default codes → time-band codes → holiday code; de-duplicated (first-seen wins); null/whitespace codes skipped. | +| 5 | The global `allPayCodes`, the single-worker export, and `siteTotalsByPayCode` (which feeds the Total sheet) are **not** modified. | + +## Architecture + +### New unit: `GetDeclaredPayCodes(PayRuleSet payRuleSet) : List` + +A pure, side-effect-free helper on `TimePlanningWorkingHoursService` (placed near +`CalculatePayLinesForDay`). Returns the ordered, de-duplicated, non-empty pay codes declared by +the rule-set, collected in this order: + +1. For each `DayRule` in `payRuleSet.DayRules`: for each `Tier` in `DayRule.Tiers` → `Tier.PayCode`. +2. For each `DayTypeRule` in `payRuleSet.DayTypeRules`: `DayTypeRule.DefaultPayCode`, then for each + `TimeBandRule` in `DayTypeRule.TimeBandRules` → `TimeBandRule.PayCode`. +3. `payRuleSet.HolidayPaidOffPayCode`. + +Rules: skip `null`/whitespace codes; de-duplicate preserving first-seen order; return an **empty +list** when `payRuleSet` is `null`. Collection-navigation order follows the loaded entity +(the existing `.Include` order); within tiers, use `Tier.Order` if the existing code already +orders by it (match existing convention — verify in the plan). + +> What it does: turns a pay-rule-set into the exact, stable set of pay-code column names for a +> worker. How it's used: called once per site in the per-site loop. Depends on: only the +> `PayRuleSet` entity graph already loaded into `AllWorkersSiteCache`. + +### Per-site loop changes (the only behavioural change) + +Inside the per-site loop of the all-workers export, after retrieving +`cache = perSiteCache[siteId]`, compute: + +``` +var sitePayCodes = GetDeclaredPayCodes(cache?.PayRuleSet); // empty when no rule-set +``` + +Then replace `allPayCodes` with `sitePayCodes` at the three **per-site** emission points only: + +1. **Per-site sheet header** (~3193–3197): append one header per `sitePayCodes` (was `allPayCodes`). +2. **Per-day cell fill** (~3267–3271): for each `payCode in sitePayCodes`, append the day's + computed pay-line hours for that code (`dayPayLines.FirstOrDefault(pl => pl.PayCode == payCode)?.Hours ?? 0`) — same matching as today, over the per-site list. +3. **Per-site totals row** (~3346–3349): iterate `sitePayCodes`, using a **new per-site totals + dictionary** seeded from `sitePayCodes` (accumulated across the site's days), instead of the + shared `siteTotalsByPayCode`. + +**Do not change** the global `allPayCodes` pre-pass, the `siteTotalsByPayCode` accumulation +(keep it as-is so the Total-sheet row stays correct), the Total-sheet header/row, or the +single-worker export. + +> Note on the two totals dictionaries: `siteTotalsByPayCode` (keyed by `allPayCodes`) stays for +> the Total sheet. A separate per-site totals dict keyed by `sitePayCodes` drives the per-site +> totals row. This avoids a `KeyNotFound` when `sitePayCodes` contains a declared code that never +> appeared in any computed pay line (so it isn't in `allPayCodes`), and keeps the two sheets +> independent. + +### Column-count consistency within a per-site sheet + +The per-site sheet's header, every data row, and the totals row must all emit the same number of +pay-code cells (= `sitePayCodes.Count`). Since all three now iterate the same `sitePayCodes`, +they stay aligned. The sheet's `AutoFilter`/column-count references must reflect the per-site +column count (it already derives from the built header list — verify in the plan). + +## Data flow + +``` +per-site loop (all-workers export): + cache = perSiteCache[siteId] (has PayRuleSet, PayLinesByDate) + sitePayCodes = GetDeclaredPayCodes(cache?.PayRuleSet) // [] if no rule-set + header += sitePayCodes + for each planning day: + for code in sitePayCodes: + hours = cache.PayLinesByDate[day].FirstOrDefault(pl => pl.PayCode == code)?.Hours ?? 0 + cell += hours ; perSiteTotals[code] += hours + totals row += perSiteTotals[code] for code in sitePayCodes + + (Total sheet: unchanged — still uses allPayCodes + siteTotalsByPayCode) +``` + +## Edge cases + +- **No pay-rule-set**: `GetDeclaredPayCodes(null)` → empty → zero pay-code columns on that sheet. + The Total sheet still shows the union columns with `0` in that worker's row (unchanged). +- **Declared code with 0 hours this period**: appears as a column, all cells `0` (the "declared" + choice). +- **Duplicate codes across rules**: de-duplicated to one column. +- **Different rule-sets across workers**: each sheet gets its own (possibly different-width) + pay-code column block — exactly the goal. + +## Testing + +- **Unit** (`PayRuleSet…`/service test, in an existing CI shard): `GetDeclaredPayCodes` — + collects codes from all four sources, de-dups, preserves structural order, skips null/empty, + and returns empty for `null`. +- **Export E2E** (`DagsoversigtWorksheetExportTests` or `WorkingHoursExcelExportE2ETests`): seed + **two sites with different pay-rule-sets** (distinct declared codes) **plus one site with no + rule-set**, run the all-workers export, and assert: + - each per-site sheet's pay-code header = exactly that site's declared codes (and the + no-rule-set sheet has none); + - the Total sheet still contains the union of codes (unchanged behaviour); + - cell values for a declared-but-zero code are `0`. + +## Out of scope + +- The Total summary sheet (unchanged by explicit decision). +- The single-worker export (already correct). +- Pay-line computation (`CalculatePayLinesForDay`), pay-rule-set CRUD, or payroll-file exports. +- The pre-existing data/encoding and temp-filename issues tracked separately. + +## Files to change + +Host-app mirror `eform-angular-frontend/eFormAPI/Plugins/TimePlanning.Pn/`: +- `TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` + — add `GetDeclaredPayCodes`; wire `sitePayCodes` into the three per-site emission points + a + per-site totals dict. +- `TimePlanning.Pn.Test/...` — new unit test for `GetDeclaredPayCodes`; extend an export test + with the multi-rule-set scenario. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs index 5af5c652..1e39c9cc 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; @@ -10,6 +11,7 @@ using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NSubstitute; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -335,6 +337,76 @@ await SeedSiteAndPlanRegistration( await allResult.Model!.DisposeAsync(); } + // ------------------------------------------------------------------ + // 6. All-workers: each per-site sheet exposes only the pay codes DECLARED + // in that site's own pay-rule-set; the Total sheet keeps the union. + // ------------------------------------------------------------------ + + [Test] + public async Task AllWorkers_PerSiteSheets_UseEachWorkersOwnPayRuleSetCodes() + { + var date = new DateTime(2026, 5, 15); + + // Site A declares only "AAA"; Site B declares only "BBB"; Site C has no + // pay-rule-set at all. Each site has a worked-time registration on `date`. + await SeedSiteAndPlanRegistration( + siteUid: 9701, employeeNo: "1", date: date, + useOneMinuteIntervals: false, start1Id: 97, stop1Id: 121); + await LinkPayRuleSetToSite(siteUid: 9701, name: "RuleSet A", payCode: "AAA"); + + await SeedSiteAndPlanRegistration( + siteUid: 9702, employeeNo: "2", date: date, + useOneMinuteIntervals: false, start1Id: 97, stop1Id: 121); + await LinkPayRuleSetToSite(siteUid: 9702, name: "RuleSet B", payCode: "BBB"); + + await SeedSiteAndPlanRegistration( + siteUid: 9703, employeeNo: "3", date: date, + useOneMinuteIntervals: false, start1Id: 97, stop1Id: 121); + // Site C: intentionally no LinkPayRuleSetToSite call → PayRuleSetId stays null. + + var result = await _service.GenerateExcelDashboard( + new TimePlanningWorkingHoursReportForAllWorkersRequestModel + { + DateFrom = date, + DateTo = date, + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model, Is.Not.Null); + + try + { + result.Model!.Position = 0; + using var doc = SpreadsheetDocument.Open(result.Model!, false); + var workbookPart = doc.WorkbookPart!; + + var siteAHeader = ReadHeaderRowText(workbookPart, "Site 9701"); + var siteBHeader = ReadHeaderRowText(workbookPart, "Site 9702"); + var siteCHeader = ReadHeaderRowText(workbookPart, "Site 9703"); + var totalHeader = ReadHeaderRowText(workbookPart, "Total"); + + // Site A's sheet: only its own declared code. + Assert.That(siteAHeader, Does.Contain("AAA"), "Site A sheet must declare its own code AAA"); + Assert.That(siteAHeader, Does.Not.Contain("BBB"), "Site A sheet must NOT carry Site B's code BBB"); + + // Site B's sheet: only its own declared code. + Assert.That(siteBHeader, Does.Contain("BBB"), "Site B sheet must declare its own code BBB"); + Assert.That(siteBHeader, Does.Not.Contain("AAA"), "Site B sheet must NOT carry Site A's code AAA"); + + // Site C has no rule-set: neither code appears. + Assert.That(siteCHeader, Does.Not.Contain("AAA"), "Site C (no rule-set) must NOT carry AAA"); + Assert.That(siteCHeader, Does.Not.Contain("BBB"), "Site C (no rule-set) must NOT carry BBB"); + + // Total sheet keeps the union of all codes (unchanged behavior). + Assert.That(totalHeader, Does.Contain("AAA"), "Total sheet must keep the union, including AAA"); + Assert.That(totalHeader, Does.Contain("BBB"), "Total sheet must keep the union, including BBB"); + } + finally + { + await result.Model!.DisposeAsync(); + } + } + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ @@ -391,6 +463,71 @@ private static string CellText(Cell c, WorkbookPart wb) return raw; } + /// + /// Resolves the sheet named from the workbook and + /// returns the joined text of every header cell (row 1). Pay-code headers are + /// plain string cells appended after the fixed columns, so a substring search + /// of the joined text reliably tells whether a code column is present. + /// + private static string ReadHeaderRowText(WorkbookPart workbookPart, string sheetName) + { + var sheet = workbookPart.Workbook.Descendants() + .First(s => s.Name == sheetName); + var part = (WorksheetPart)workbookPart.GetPartById(sheet.Id!); + var headerRow = part.Worksheet.Descendants().First(r => r.RowIndex! == 1U); + return string.Join("|", headerRow.Elements().Select(c => CellText(c, workbookPart))); + } + + /// + /// Creates a declaring exactly one pay code (one + /// → one ) and links it to the + /// already-seeded site's by setting + /// PayRuleSetId. The declared code drives the per-site sheet's pay-code + /// header columns regardless of worked time. + /// + private async Task LinkPayRuleSetToSite(int siteUid, string name, string payCode) + { + var payRuleSet = new PayRuleSet + { + Name = name, + DayRules = new List + { + new PayDayRule + { + DayCode = "WEEKDAY", + Tiers = new List + { + new PayTierRule { UpToSeconds = null, PayCode = payCode, PayrollCode = "100", Order = 1 } + } + } + }, + DayTypeRules = new List(), + WorkflowState = Constants.WorkflowStates.Created, + }; + await payRuleSet.Create(TimePlanningPnDbContext!); + + var assignedSite = await TimePlanningPnDbContext!.AssignedSites + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstAsync(x => x.SiteId == siteUid); + assignedSite.PayRuleSetId = payRuleSet.Id; + await assignedSite.Update(TimePlanningPnDbContext!); + + // Give the in-range registration positive NettoHours so the WEEKDAY tier + // emits a pay line for the declared code. The per-site COLUMN header comes + // from the DECLARED codes (worked time irrelevant), but the Total sheet's + // union is built from EMITTED pay codes, so it needs non-zero worked time. + var registrations = await TimePlanningPnDbContext!.PlanRegistrations + .Where(x => x.SdkSitId == siteUid + && x.WorkflowState != Constants.WorkflowStates.Removed + && x.Start1Id > 0) + .ToListAsync(); + foreach (var registration in registrations) + { + registration.NettoHours = 2.0; + await registration.Update(TimePlanningPnDbContext!); + } + } + /// /// Seeds one SDK Site/Worker/SiteWorker + an AssignedSite + a prior-day and a /// requested-day PlanRegistration. Mirrors the helper in diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index 70e1fc5f..fe8978f0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; @@ -1341,4 +1342,50 @@ public void RecalculatePlanHoursFromShifts_PreservesRoundPlannedMinutes(bool use Assert.That(pr.PlanHours, Is.EqualTo(2.0).Within(0.001)); Assert.That(pr.PlanHoursInSeconds, Is.EqualTo(7200)); } + + [Test] + public void GetDeclaredPayCodes_CollectsFromAllSources_DedupsAndSkipsEmpty_OrdersTiersByOrder() + { + var ruleSet = new PayRuleSet + { + DayRules = new List + { + new PayDayRule { Tiers = new List + { + new PayTierRule { PayCode = "B", Order = 2 }, + new PayTierRule { PayCode = "A", Order = 1 }, + }}, + new PayDayRule { Tiers = new List + { + new PayTierRule { PayCode = "A", Order = 1 }, // duplicate + }}, + }, + DayTypeRules = new List + { + new PayDayTypeRule { DefaultPayCode = "C", TimeBandRules = new List + { + new PayTimeBandRule { PayCode = "D" }, + new PayTimeBandRule { PayCode = "" }, // skipped + }}, + new PayDayTypeRule { DefaultPayCode = null, TimeBandRules = new List() }, // skipped + }, + HolidayPaidOffPayCode = "E", + }; + + var codes = TimePlanningWorkingHoursService.GetDeclaredPayCodes(ruleSet); + + Assert.That(codes, Is.EqualTo(new List { "A", "B", "C", "D", "E" })); + } + + [Test] + public void GetDeclaredPayCodes_NullRuleSet_ReturnsEmpty() + { + Assert.That(TimePlanningWorkingHoursService.GetDeclaredPayCodes(null), Is.Empty); + } + + [Test] + public void GetDeclaredPayCodes_EmptyRuleSet_ReturnsEmpty() + { + Assert.That(TimePlanningWorkingHoursService.GetDeclaredPayCodes(new PayRuleSet()), Is.Empty); + } } \ No newline at end of file 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 31b47d78..ebf088c5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -3190,8 +3190,12 @@ public async Task> GenerateExcelDashboard( { headerStrings.Add(localizationService.GetString(header)); } - // Append one column header per pay code discovered across all sites - foreach (var payCode in allPayCodes) + // Per-worker pay-code columns: only the codes declared in THIS site's + // pay-rule-set (empty when the site has no rule-set). The global allPayCodes + // is still used by the Total sheet, which is intentionally unchanged. + perSiteCache.TryGetValue(siteIds[i], out var siteCacheForCodes); + var sitePayCodes = GetDeclaredPayCodes(siteCacheForCodes?.PayRuleSet); + foreach (var payCode in sitePayCodes) { headerStrings.Add(payCode); } @@ -3264,7 +3268,7 @@ public async Task> GenerateExcelDashboard( var dayPayLines = (cache != null && cache.PayLinesByDate.ContainsKey(planning.Date)) ? cache.PayLinesByDate[planning.Date] : new List(); - foreach (var payCode in allPayCodes) + foreach (var payCode in sitePayCodes) { var payLine = dayPayLines.FirstOrDefault(pl => pl.PayCode == payCode); dataRow.Append(CreateNumericCell(payLine?.Hours ?? 0)); @@ -3343,9 +3347,9 @@ public async Task> GenerateExcelDashboard( siteTotalsRow.Append(CreateCell(string.Empty)); siteTotalsRow.Append(CreateCell(string.Empty)); } - foreach (var payCode in allPayCodes) + foreach (var payCode in sitePayCodes) { - siteTotalsRow.Append(CreateNumericCell(siteTotalsByPayCode[payCode])); + siteTotalsRow.Append(CreateNumericCell(siteTotalsByPayCode.GetValueOrDefault(payCode, 0))); } sheetData1.Append(siteTotalsRow); rowIndex++; @@ -3936,6 +3940,58 @@ private static List MergeByPayCode(List + /// Returns the pay codes DECLARED by a pay-rule-set, in structural order + /// (day-rule tiers by Order, then day-type default codes and their time-band codes, + /// then the holiday code), de-duplicated (first-seen wins), skipping null/empty codes. + /// Returns an empty list when payRuleSet is null. + /// + internal static List GetDeclaredPayCodes(PayRuleSet payRuleSet) + { + var codes = new List(); + if (payRuleSet == null) + { + return codes; + } + + void Add(string code) + { + if (!string.IsNullOrWhiteSpace(code) && !codes.Contains(code)) + { + codes.Add(code); + } + } + + if (payRuleSet.DayRules != null) + { + foreach (var dayRule in payRuleSet.DayRules) + { + if (dayRule.Tiers == null) continue; + foreach (var tier in dayRule.Tiers.OrderBy(t => t.Order)) + { + Add(tier.PayCode); + } + } + } + + if (payRuleSet.DayTypeRules != null) + { + foreach (var dayTypeRule in payRuleSet.DayTypeRules) + { + Add(dayTypeRule.DefaultPayCode); + if (dayTypeRule.TimeBandRules == null) continue; + foreach (var timeBand in dayTypeRule.TimeBandRules) + { + Add(timeBand.PayCode); + } + } + } + + Add(payRuleSet.HolidayPaidOffPayCode); + + return codes; + } + /// /// Calculates pay lines for a single day, choosing time-band rules when defined /// for the day's DayType, otherwise falling back to tier-based logic on totalSeconds.