From ea13f5019ebb1758be12c6e68a3e2caa2eadf532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 7 Aug 2026 14:48:59 +0200 Subject: [PATCH] WIP fix(praktikant): normal-time/overtime split, opt-in by preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not ready to merge — the weekly-hours question is still under research and may change the model. Splits a praktikant day at the normal-time boundary: minutes up to it are attributed by clock-time bands, minutes beyond it to the overtime tiers, because § 50 stk. 4 d pays the stald supplements only 'for arbejde i normal arbejdstid'. The split is opt-in by preset name (normalised so 2024-2026 and 2026-2029 rows both match). An earlier revision gated on rule-set SHAPE instead, which silently caught 13 non-praktikant preset/day combinations whose tier 1 is a mirror of their clock split rather than a normal-time boundary — that would have awarded afternoon supplements for morning-only Saturdays across seven other collective agreements. Truncation is pause-aware: the budget is netto but the segments are gross, so without this a pause inside the normal-time window pushed the boundary earlier and cost the worker an hour of Saturday-afternoon supplement. Grundlovsdag gets its § 29 noon split in the plugin, since TryGetDayType rejects GRUNDLOVSDAG so bands can never reach it. The normal-time boundary applies first, then the clock split picks the code, with the afternoon treatment read from the preset's own SUNDAY rule rather than hardcoded. 45 routing tests, including a guard that a non-praktikant banded preset still takes the bands-only path — the test whose absence let the scope breach through. Co-Authored-By: Claude Fable 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../Helpers/PraktikantFixtures.cs | 205 ++++ .../PraktikantPayLineRoutingTests.cs | 879 ++++++++++++++++++ .../Infrastructure/Helpers/PayRuleSetLock.cs | 55 ++ .../TimePlanningWorkingHoursService.cs | 545 ++++++++++- .../pay-rule-sets/pay-rule-set-presets.ts | 89 +- 7 files changed, 1728 insertions(+), 49 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 375e8528..556dfc97 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -250,7 +250,7 @@ jobs: - name: c filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|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" - 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.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" + 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 filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests" - name: f diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 91706480..08503e51 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -239,7 +239,7 @@ jobs: - 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" - 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.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" + 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 filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests" - name: f diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs new file mode 100644 index 00000000..3910b2f8 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs @@ -0,0 +1,205 @@ +using System.Collections.Generic; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; + +namespace TimePlanning.Pn.Test.Helpers; + +/// +/// In-memory fixtures for the two "Udenlandske praktikanter +/// Landbrug" presets (GLS-A / 3F, § 50), used by +/// . +/// +/// THREE-WAY SYNC OBLIGATION — these two presets are locked: true, so the same +/// tiers/bands exist in THREE places and must be changed together, byte-for-byte: +/// 1. the frontend catalogue +/// eform-client/src/app/plugins/modules/time-planning-pn/models/pay-rule-sets/pay-rule-set-presets.ts +/// (keys 'glsa-jordbrug-praktikant-udl-staldarbejde' and 'glsa-jordbrug-praktikant-udl-andet'); +/// 2. the base fixtures OverenskomstFixtureHelper / GlsAFixtureHelper in +/// eform-timeplanning-base (Microting.TimePlanningBase.Tests); +/// 3. this file. +/// This helper is a local copy ONLY because the plugin test project does not reference +/// the base test project, so the base fixture helpers are not visible here. If you edit +/// the preset in the TS catalogue, edit the base fixture AND this file in the same change. +/// Values below were transcribed from the TS catalogue on 2026-08-07. +/// +/// THE Name IS LOAD-BEARING, NOT DECORATION. The engine opts these two presets — +/// and only these two — into the sequential normal-time/overtime split and the +/// Grundlovsdag noon split by matching the name through +/// PayRuleSetLock.IsNormalTimeSplitPresetName. Change a Name here and the fixture +/// silently drops back to the historical bands-only / pure-tier routing. +/// +internal static class PraktikantFixtures +{ + /// Daily normal-time boundary: 7 h 24 m. Documented system default (37 h ÷ 5). + internal const int NormSeconds = 26640; + + /// Cumulative end of the 50 % overtime step: 9 h 24 m. + internal const int Overtime50UpToSeconds = 33840; + + /// + /// Preset 39: "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde 2026-2029". + /// Stald supplements are payable per § 50 stk. 4 d only "for arbejde i normal + /// arbejdstid", hence bands for the normal-time portion plus overtime tiers beyond it. + /// On Grundlovsdag the plugin splits the normal-time portion at 12:00: before noon + /// the GRUNDLOVSDAG tier-1 code (NORMAL), from noon the Sunday day-type band + /// (ANIMAL_SUN_HOLIDAY). + /// + internal static PayRuleSet Staldarbejde() => new() + { + Id = 3901, + Name = "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde 2026-2029", + DayRules = new List + { + new() + { + DayCode = "WEEKDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "SATURDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "SAT_NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "SUNDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "ANIMAL_SUN_HOLIDAY" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "HOLIDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "ANIMAL_SUN_HOLIDAY" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "GRUNDLOVSDAG", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + }, + DayTypeRules = new List + { + new() + { + DayType = DayType.Saturday, + DefaultPayCode = "SAT_NORMAL", + Priority = 1, + TimeBandRules = new List + { + new() { StartSecondOfDay = 0, EndSecondOfDay = 43200, PayCode = "SAT_NORMAL", Priority = 1 }, + new() { StartSecondOfDay = 43200, EndSecondOfDay = 86400, PayCode = "SAT_ANIMAL_AFTERNOON", Priority = 1 }, + } + }, + new() + { + DayType = DayType.Sunday, + DefaultPayCode = "ANIMAL_SUN_HOLIDAY", + Priority = 1, + TimeBandRules = new List + { + new() { StartSecondOfDay = 0, EndSecondOfDay = 86400, PayCode = "ANIMAL_SUN_HOLIDAY", Priority = 1 }, + } + }, + new() + { + DayType = DayType.Holiday, + DefaultPayCode = "ANIMAL_SUN_HOLIDAY", + Priority = 1, + TimeBandRules = new List + { + new() { StartSecondOfDay = 0, EndSecondOfDay = 86400, PayCode = "ANIMAL_SUN_HOLIDAY", Priority = 1 }, + } + }, + }, + }; + + /// + /// Preset 38: "GLS-A / 3F - Udenlandske praktikanter Landbrug Andet arbejde 2026-2029". + /// No day-type rules at all — every day goes down the pure tier path, EXCEPT + /// Grundlovsdag, which the plugin splits at 12:00: before noon the GRUNDLOVSDAG + /// tier-1 code (NORMAL), from noon this preset's søgnehelligdag treatment, which the + /// engine reads off the SUNDAY day rule below. Sundays and holidays are outside the + /// permitted Mon–Sat 06–18 window, so all hours there are overtime (first 2 h @ 50 %, + /// remainder @ 80 %). + /// + internal static PayRuleSet AndetArbejde() => new() + { + Id = 3801, + Name = "GLS-A / 3F - Udenlandske praktikanter Landbrug Andet arbejde 2026-2029", + DayRules = new List + { + new() + { + DayCode = "WEEKDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "SATURDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "SUNDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 7200, PayCode = "OVERTIME_50" }, + new() { Order = 2, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "HOLIDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 7200, PayCode = "OVERTIME_50" }, + new() { Order = 2, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + new() + { + DayCode = "GRUNDLOVSDAG", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + } + }, + }, + DayTypeRules = new List(), + }; +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs new file mode 100644 index 00000000..6a327ed4 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs @@ -0,0 +1,879 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; +using TimePlanning.Pn.Test.Helpers; + +namespace TimePlanning.Pn.Test; + +/// +/// End-to-end coverage of the two "Udenlandske praktikanter Landbrug" presets (GLS-A / 3F, +/// § 50) THROUGH — +/// the real router — rather than through the generators directly. +/// +/// The headline behaviour under test (2026-08-07 spec): normal time and overtime are +/// SEQUENTIAL, not cumulative. § 50 stk. 4 d pays the stald supplements only "for arbejde +/// i normal arbejdstid", so minutes up to the daily norm (7 h 24 m = 26640 s, the first +/// tier's UpToSeconds) are attributed by clock-time BANDS, and every minute past that +/// boundary goes to the overtime TIERS from tier 2 onward, carrying no supplement code. +/// Before the fix the routing was exclusive — any band for the day meant the tiers never +/// ran and all overtime minutes were silently lost. +/// +/// THAT READING IS OPT-IN BY RULE-SET NAME, not by rule-set shape — thirteen other +/// preset/day combinations have the same shape but encode tier 1 as a mirror of their +/// clock split, and are out of scope. Section 9 guards that boundary; section 10 guards +/// that pause seconds never consume the normal-time budget (totalSeconds is NETTO, so +/// charging pauses to it would steal afternoon supplement from the worker). +/// +/// Day classification is date-driven via : +/// Grundlovsdag (5 June) is checked FIRST and beats Sunday, then official holidays from +/// Resources/danish_holidays_2025_2030.json, then Sunday, Saturday, else weekday. +/// +/// Every test runs the conservation invariant: the sum of +/// HoursInSeconds across all returned lines must equal the expected worked seconds, and +/// the per-code expectations must themselves sum to that same number. No minute may be +/// lost or double-counted. +/// +/// Fixtures live in — see the three-way sync note there. +/// +[TestFixture] +public class PraktikantPayLineRoutingTests +{ + // ---- Verified calendar anchors (checked against danish_holidays_2025_2030.json) ---- + + /// Monday 11 May 2026 — plain weekday, not an official holiday. + private static readonly DateTime Weekday = new(2026, 5, 11); + + /// Saturday 16 May 2026 — not an official holiday. + private static readonly DateTime Saturday = new(2026, 5, 16); + + /// Sunday 17 May 2026 — not an official holiday. + private static readonly DateTime Sunday = new(2026, 5, 17); + + /// Thursday 14 May 2026 — Kristi himmelfartsdag, category official_holiday. + private static readonly DateTime Holiday = new(2026, 5, 14); + + /// Friday 5 June 2026 — Grundlovsdag; the 5-June check wins over every other rule. + private static readonly DateTime Grundlovsdag = new(2026, 6, 5); + + private const int Hour = 3600; + private const int Norm = PraktikantFixtures.NormSeconds; // 26640 s = 7 h 24 m + + // ------------------------------------------------------------------ + // 1. Daily-norm boundary on a WEEKDAY (pure tier path — neither preset + // defines day-type bands for Monday, so totalSeconds drives it). + // ------------------------------------------------------------------ + + [Test] + public void Stald_Weekday_6h_BelowNorm_AllNormal() + => RunWeekdayBoundaryCase(PraktikantFixtures.Staldarbejde(), 6 * Hour, + ("NORMAL", 21600)); + + [Test] + public void Stald_Weekday_ExactlyAtNorm_7h24m_AllNormal_NoOvertime() + => RunWeekdayBoundaryCase(PraktikantFixtures.Staldarbejde(), 26640, + ("NORMAL", 26640)); + + [Test] + public void Stald_Weekday_7h25m_JustOverNorm_OneMinuteToOvertime50() + => RunWeekdayBoundaryCase(PraktikantFixtures.Staldarbejde(), 26700, + ("NORMAL", 26640), ("OVERTIME_50", 60)); + + [Test] + public void Stald_Weekday_9h24m_TopOfOvertime50Tier_NothingAt80() + => RunWeekdayBoundaryCase(PraktikantFixtures.Staldarbejde(), 33840, + ("NORMAL", 26640), ("OVERTIME_50", 7200)); + + [Test] + public void Stald_Weekday_12h_SpillsIntoOvertime80() + => RunWeekdayBoundaryCase(PraktikantFixtures.Staldarbejde(), 12 * Hour, + ("NORMAL", 26640), ("OVERTIME_50", 7200), ("OVERTIME_80", 9360)); + + [Test] + public void Andet_Weekday_6h_BelowNorm_AllNormal() + => RunWeekdayBoundaryCase(PraktikantFixtures.AndetArbejde(), 6 * Hour, + ("NORMAL", 21600)); + + [Test] + public void Andet_Weekday_ExactlyAtNorm_7h24m_AllNormal_NoOvertime() + => RunWeekdayBoundaryCase(PraktikantFixtures.AndetArbejde(), 26640, + ("NORMAL", 26640)); + + [Test] + public void Andet_Weekday_7h25m_JustOverNorm_OneMinuteToOvertime50() + => RunWeekdayBoundaryCase(PraktikantFixtures.AndetArbejde(), 26700, + ("NORMAL", 26640), ("OVERTIME_50", 60)); + + [Test] + public void Andet_Weekday_9h24m_TopOfOvertime50Tier_NothingAt80() + => RunWeekdayBoundaryCase(PraktikantFixtures.AndetArbejde(), 33840, + ("NORMAL", 26640), ("OVERTIME_50", 7200)); + + [Test] + public void Andet_Weekday_12h_SpillsIntoOvertime80() + => RunWeekdayBoundaryCase(PraktikantFixtures.AndetArbejde(), 12 * Hour, + ("NORMAL", 26640), ("OVERTIME_50", 7200), ("OVERTIME_80", 9360)); + + // ------------------------------------------------------------------ + // 2. Staldarbejde SATURDAY — bands for normal time, tiers for the rest. + // This is the headline fix. + // ------------------------------------------------------------------ + + [Test] + public void Stald_Saturday_12h_BandsUntilNorm_ThenOvertimeTiers() + { + // Shift 06:00 → 18:00 on Sat 16 May 2026 = 43200 s worked. + // bandSeconds = min(43200, 26640) = 26640 + // overtimeSeconds = 43200 - 26640 = 16560 + // Truncated segment: (21600, 21600 + 26640 = 48240) — i.e. 06:00 → 13:24. + // band 00:00–12:00 SAT_NORMAL : 21600 → 43200 = 21600 s + // band 12:00–24:00 SAT_ANIMAL_AFTERNOON : 43200 → 48240 = 5040 s + // Overtime, cumulative starts at 26640: + // tier 2 UpTo 33840 → cap 33840 - 26640 = 7200 → OVERTIME_50 = 7200 s + // tier 3 UpTo null → remainder → OVERTIME_80 = 9360 s + // 21600 + 5040 + 7200 + 9360 = 43200. + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + SingleShift(Saturday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("SAT_NORMAL", 21600), + ("SAT_ANIMAL_AFTERNOON", 5040), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + [Test] + public void Stald_Saturday_ExactlyAtNorm_AllBanded_NoOvertimeLines() + { + // 06:00 → 13:24 = 26640 s, exactly the norm. Nothing spills into the tiers. + // SAT_NORMAL 06:00 → 12:00 = 21600 s + // SAT_ANIMAL_AFTERNOON 12:00 → 13:24 = 5040 s + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + SingleShift(Saturday, 6 * Hour, Norm), Norm); + + AssertPayLines(lines, 26640, + ("SAT_NORMAL", 21600), + ("SAT_ANIMAL_AFTERNOON", 5040)); + } + + [Test] + public void Stald_Saturday_OneMinuteOverNorm_BandsUnchanged_OneMinuteToOvertime50() + { + // 26700 s from 06:00. Bands still see only the first 26640 s (06:00 → 13:24), + // so the band split is identical to the exactly-at-norm case; the extra 60 s + // becomes overtime and carries NO supplement code. + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + SingleShift(Saturday, 6 * Hour, 26700), 26700); + + AssertPayLines(lines, 26700, + ("SAT_NORMAL", 21600), + ("SAT_ANIMAL_AFTERNOON", 5040), + ("OVERTIME_50", 60)); + } + + [Test] + public void Stald_Saturday_4h_EntirelyBeforeNoon_AllSatNormal_NoOvertime() + { + // 08:00 → 12:00 = 14400 s, below the norm, wholly inside the 00:00–12:00 band. + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + SingleShift(Saturday, 8 * Hour, 4 * Hour), 4 * Hour); + + AssertPayLines(lines, 14400, ("SAT_NORMAL", 14400)); + } + + [Test] + public void Stald_Saturday_4h_EntirelyAfterNoon_AllSatAnimalAfternoon_NoOvertime() + { + // 13:00 → 17:00 = 14400 s, below the norm, wholly inside the 12:00–24:00 band. + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + SingleShift(Saturday, 13 * Hour, 4 * Hour), 4 * Hour); + + AssertPayLines(lines, 14400, ("SAT_ANIMAL_AFTERNOON", 14400)); + } + + [Test] + public void Stald_Saturday_TwoShifts_TruncationSplitsSecondShiftAtNorm() + { + // Shift 1 06:00 → 11:00 = 18000 s, shift 2 12:00 → 19:00 = 25200 s. Total 43200 s. + // bandSeconds = 26640. EnumerateShiftSegmentsTruncated consumes shift 1 whole + // (18000 ≤ 26640, remaining 8640), then splits shift 2 at 12:00 + 8640 s = 14:24, + // yielding (43200, 51840). Later time is not band-attributed. + // SAT_NORMAL 06:00 → 11:00 = 18000 s + // SAT_ANIMAL_AFTERNOON 12:00 → 14:24 = 8640 s (18000 + 8640 = 26640 ✓) + // overtimeSeconds = 43200 - 26640 = 16560 → OVERTIME_50 7200, OVERTIME_80 9360. + var model = new TimePlanningWorkingHoursModel + { + Date = Saturday, + Start1StartedAt = Saturday.AddHours(6), + Stop1StoppedAt = Saturday.AddHours(11), + Start2StartedAt = Saturday.AddHours(12), + Stop2StoppedAt = Saturday.AddHours(19), + }; + + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), model, 43200); + + AssertPayLines(lines, 43200, + ("SAT_NORMAL", 18000), + ("SAT_ANIMAL_AFTERNOON", 8640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + // ------------------------------------------------------------------ + // 3. Staldarbejde SUNDAY / HOLIDAY — one all-day ANIMAL_SUN_HOLIDAY band + // for normal time, then the overtime tiers. + // ------------------------------------------------------------------ + + [Test] + public void Stald_Sunday_6h_BelowNorm_AllAnimalSunHoliday() + { + var lines = Run(Sunday, PraktikantFixtures.Staldarbejde(), + SingleShift(Sunday, 6 * Hour, 6 * Hour), 6 * Hour); + + AssertPayLines(lines, 21600, ("ANIMAL_SUN_HOLIDAY", 21600)); + } + + [Test] + public void Stald_Sunday_12h_AnimalUntilNorm_ThenOvertimeTiers() + { + // 06:00 → 18:00 = 43200 s. Bands see the first 26640 s only, and the single + // 00:00–24:00 band puts all of it on ANIMAL_SUN_HOLIDAY. Remaining 16560 s: + // OVERTIME_50 7200 (up to 33840 cumulative), OVERTIME_80 9360. + var lines = Run(Sunday, PraktikantFixtures.Staldarbejde(), + SingleShift(Sunday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("ANIMAL_SUN_HOLIDAY", 26640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + [Test] + public void Stald_Holiday_6h_BelowNorm_AllAnimalSunHoliday() + { + // Thu 14 May 2026 (Kristi himmelfartsdag) → dayCode HOLIDAY → DayType.Holiday, + // NOT DayType.Thursday. + var lines = Run(Holiday, PraktikantFixtures.Staldarbejde(), + SingleShift(Holiday, 6 * Hour, 6 * Hour), 6 * Hour); + + AssertPayLines(lines, 21600, ("ANIMAL_SUN_HOLIDAY", 21600)); + } + + [Test] + public void Stald_Holiday_12h_AnimalUntilNorm_ThenOvertimeTiers() + { + var lines = Run(Holiday, PraktikantFixtures.Staldarbejde(), + SingleShift(Holiday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("ANIMAL_SUN_HOLIDAY", 26640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + // ------------------------------------------------------------------ + // 4. Andet arbejde SUNDAY / HOLIDAY — unchanged all-overtime behaviour. + // Sundays and holidays are outside the permitted Mon–Sat 06–18 window. + // ------------------------------------------------------------------ + + [Test] + public void Andet_Sunday_8h_AllOvertime_TwoHoursAt50_RemainderAt80() + { + // Andet arbejde declares no day-type rules at all, so this is the pure tier + // path: SUNDAY tiers are 7200 s OVERTIME_50 then OVERTIME_80 for the rest. + var lines = Run(Sunday, PraktikantFixtures.AndetArbejde(), + SingleShift(Sunday, 6 * Hour, 8 * Hour), 8 * Hour); + + AssertPayLines(lines, 28800, + ("OVERTIME_50", 7200), + ("OVERTIME_80", 21600)); + } + + [Test] + public void Andet_Holiday_8h_AllOvertime_TwoHoursAt50_RemainderAt80() + { + var lines = Run(Holiday, PraktikantFixtures.AndetArbejde(), + SingleShift(Holiday, 6 * Hour, 8 * Hour), 8 * Hour); + + AssertPayLines(lines, 28800, + ("OVERTIME_50", 7200), + ("OVERTIME_80", 21600)); + } + + [Test] + public void Andet_Sunday_1h_BelowFirstTier_AllOvertime50() + { + var lines = Run(Sunday, PraktikantFixtures.AndetArbejde(), + SingleShift(Sunday, 6 * Hour, 1 * Hour), 1 * Hour); + + AssertPayLines(lines, 3600, ("OVERTIME_50", 3600)); + } + + // ------------------------------------------------------------------ + // 5. GRUNDLOVSDAG — the § 29 noon split, applied plugin-side. + // + // Jordbrug § 29 makes Grundlovsdag a half day: minutes before 12:00 are ordinary + // working time, minutes from 12:00 follow søgnehelligdag treatment (decision 4 of + // the 2026-08-07 spec). TryGetDayType returns FALSE for GRUNDLOVSDAG, so there is + // no DayType to hang time bands on and the split cannot live in the preset data — + // CalculateGrundlovsdagPayLines applies it instead, for the two opted-in presets. + // + // PRECEDENCE the tests below lock in (the two rules compose, they do not conflict): + // 1. the 26640 s normal-time boundary partitions the day FIRST and absolutely — + // only those seconds are eligible for a day-classification code at all; + // 2. within them, the 12:00 clock split picks NORMAL vs søgnehelligdag; + // 3. "søgnehelligdag treatment" is read off the preset's own SUNDAY rule — + // an ANIMAL_SUN_HOLIDAY band for stald, the 7200/rest OVERTIME_50/OVERTIME_80 + // ladder for andet arbejde. + // For andet arbejde, step 3 and the step-1 overflow both emit OVERTIME_50, and the + // merged line is simply their sum — the minute sets are disjoint by construction. + // ------------------------------------------------------------------ + + [Test] + public void Stald_Grundlovsdag_SpanningNoon_BelowNorm_SplitsAtNoon() + { + // 08:00 → 14:00 = 21600 s, below the 26640 s norm, so nothing is overtime and + // the whole day is eligible for a day-classification code. + // 08:00 → 12:00 = 14400 s → NORMAL (ordinary working time) + // 12:00 → 14:00 = 7200 s → ANIMAL_SUN_HOLIDAY (stald Sunday band) + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), + SingleShift(Grundlovsdag, 8 * Hour, 6 * Hour), 6 * Hour); + + AssertPayLines(lines, 21600, + ("NORMAL", 14400), + ("ANIMAL_SUN_HOLIDAY", 7200)); + } + + [Test] + public void Stald_Grundlovsdag_EntirelyBeforeNoon_AllNormal() + { + // 06:00 → 11:00 = 18000 s, wholly in the ordinary-working-time half of the day. + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 5 * Hour), 5 * Hour); + + AssertPayLines(lines, 18000, ("NORMAL", 18000)); + } + + [Test] + public void Stald_Grundlovsdag_EntirelyAfterNoon_AllAnimalSunHoliday() + { + // 13:00 → 17:00 = 14400 s, wholly in the søgnehelligdag half. No NORMAL line. + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), + SingleShift(Grundlovsdag, 13 * Hour, 4 * Hour), 4 * Hour); + + AssertPayLines(lines, 14400, ("ANIMAL_SUN_HOLIDAY", 14400)); + } + + [Test] + public void Stald_Grundlovsdag_12h_NoonSplitThenNormalTimeBoundary() + { + // 06:00 → 18:00 = 43200 s — the noon split AND the norm boundary both bite. + // Step 1: normal time = min(43200, 26640) = 26640 → 06:00 → 13:24; + // overtime = 43200 - 26640 = 16560. + // Step 2: 06:00 → 12:00 = 21600 s → NORMAL + // 12:00 → 13:24 = 5040 s → ANIMAL_SUN_HOLIDAY + // Step 1 overflow: OVERTIME_50 7200 (up to 33840 cumulative), OVERTIME_80 9360. + // The overtime minutes carry NEITHER NORMAL nor ANIMAL_SUN_HOLIDAY — that is the + // boundary winning over the noon split, as it does on a banded Saturday. + // 21600 + 5040 + 7200 + 9360 = 43200. + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("NORMAL", 21600), + ("ANIMAL_SUN_HOLIDAY", 5040), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + [Test] + public void Andet_Grundlovsdag_SpanningNoon_BelowNorm_AfternoonTakesSundayLadder() + { + // 08:00 → 14:00 = 21600 s, below the norm. + // 08:00 → 12:00 = 14400 s → NORMAL + // 12:00 → 14:00 = 7200 s → søgnehelligdag treatment. Andet arbejde has no + // Sunday BANDS, so its SUNDAY TIER LADDER runs over those 7200 s, restarting + // at zero: tier 1 caps at 7200 → OVERTIME_50 7200, nothing reaches tier 2. + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejde(), + SingleShift(Grundlovsdag, 8 * Hour, 6 * Hour), 6 * Hour); + + AssertPayLines(lines, 21600, + ("NORMAL", 14400), + ("OVERTIME_50", 7200)); + } + + [Test] + public void Andet_Grundlovsdag_EntirelyBeforeNoon_AllNormal() + { + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 5 * Hour), 5 * Hour); + + AssertPayLines(lines, 18000, ("NORMAL", 18000)); + } + + [Test] + public void Andet_Grundlovsdag_EntirelyAfterNoon_AllSundayLadder() + { + // 13:00 → 17:00 = 14400 s, wholly søgnehelligdag: the SUNDAY ladder gives + // OVERTIME_50 7200 then OVERTIME_80 7200. No NORMAL line at all. + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejde(), + SingleShift(Grundlovsdag, 13 * Hour, 4 * Hour), 4 * Hour); + + AssertPayLines(lines, 14400, + ("OVERTIME_50", 7200), + ("OVERTIME_80", 7200)); + } + + [Test] + public void Andet_Grundlovsdag_14h_AfternoonLadderAndOvertimeOverflowMergeByCode() + { + // THE COMPOSITION CASE. 06:00 → 20:00 = 50400 s. + // Step 1: normal time 26640 → 06:00 → 13:24; overtime 50400 - 26640 = 23760. + // Step 2: 06:00 → 12:00 = 21600 s → NORMAL + // 12:00 → 13:24 = 5040 s → SUNDAY ladder, restarting at zero → + // OVERTIME_50 5040 (below the 7200 cap) + // Step 1 overflow over the GRUNDLOVSDAG tiers 2..3, cumulative from 26640: + // OVERTIME_50 7200, OVERTIME_80 16560. + // Both sources emit OVERTIME_50; MergeByPayCode SUMS them: 5040 + 7200 = 12240. + // That is addition of two DISJOINT minute sets, not a double count — the norm + // boundary partitions the day before the noon split ever runs. + // 21600 + 12240 + 16560 = 50400. + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 14 * Hour), 14 * Hour); + + AssertPayLines(lines, 50400, + ("NORMAL", 21600), + ("OVERTIME_50", 12240), + ("OVERTIME_80", 16560)); + } + + [Test] + public void Stald_Grundlovsdag_BeatsFridayClassification_UsesGrundlovsdagRule() + { + // 5 June 2026 is a Friday. GetDayCodeForDate checks 5 June FIRST, so the + // GRUNDLOVSDAG handling wins over any weekday handling. 06:00 → 12:00 is + // entirely before noon and below the norm, so it stays all NORMAL. + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 6 * Hour), 6 * Hour); + + AssertPayLines(lines, 21600, ("NORMAL", 21600)); + } + + [Test] + public void Grundlovsdag_NonPraktikantPreset_KeepsPureTierPath_NoNoonSplit() + { + // The noon split is opted into by rule-set NAME, exactly like the normal-time + // split. A preset that is not one of the two praktikant presets keeps the + // historical behaviour: GRUNDLOVSDAG is just another day code fed to the tiers, + // with no 12:00 boundary anywhere. + var payRuleSet = PraktikantFixtures.Staldarbejde(); + payRuleSet.Name = "Some Customer Custom Rule Set"; + + var lines = Run(Grundlovsdag, payRuleSet, + SingleShift(Grundlovsdag, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("NORMAL", 26640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + Assert.That(lines.Any(l => l.PayCode == "ANIMAL_SUN_HOLIDAY"), Is.False, + "A non-opted-in preset must not get the søgnehelligdag afternoon treatment"); + } + + // ------------------------------------------------------------------ + // 6. Shift crossing midnight — documents day clamping, not an aspiration. + // ------------------------------------------------------------------ + + [Test] + public void Stald_Saturday_ShiftCrossingMidnight_ClampedAtEndOfDay_PostMidnightSecondsNotBandAttributed() + { + // Sat 16 May 22:00 → Sun 17 May 02:00. The worker worked 14400 s, but pay rules + // are scoped per-day: ResolveShiftSeconds sees realStop.Date > realStart.Date and + // CLAMPS the stop to 86400, so the segment is (79200, 86400) = 7200 s only. + // + // CONSEQUENCE (real, current behaviour — NOT an aspiration): the 7200 s worked + // after midnight are dropped on this day and are never re-attributed here. They + // are not carried into the Sunday model by this method either. So the returned + // lines conserve the CLAMPED 7200 s, not the 14400 s passed as totalSeconds. + // + // The banded branch is still entered (bandSeconds = min(14400, 26640) = 14400, + // which exceeds the clamped segment, so the whole 7200 s is band-attributed) and + // overtimeSeconds = max(0, 14400 - 26640) = 0, so no overtime line is produced + // even though the worker was on site for 14400 s across the two calendar days. + var model = new TimePlanningWorkingHoursModel + { + Date = Saturday, + Start1StartedAt = Saturday.AddHours(22), + Stop1StoppedAt = Saturday.AddDays(1).AddHours(2), + }; + + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), model, 4 * Hour); + + // 22:00 → 24:00 lies entirely in the 12:00–24:00 Saturday band. + AssertPayLines(lines, 7200, ("SAT_ANIMAL_AFTERNOON", 7200)); + + Assert.That(lines.Sum(l => l.HoursInSeconds), Is.LessThan(4 * Hour), + "Documents the clamping loss: seconds worked after midnight are not attributed to this day"); + } + + // ------------------------------------------------------------------ + // 7. No-rule day → every minute lands on NORMAL (DEFAULT → NORMAL mapping). + // ------------------------------------------------------------------ + + [Test] + public void NoMatchingDayRule_AllSecondsMappedToNormal() + { + // A pay-rule-set with a WEEKDAY rule only, evaluated on a Sunday, and with no + // day-type rules — so the tier path runs, PayLineGenerator finds no PayDayRule + // for "SUNDAY" and emits its "DEFAULT" fallback line. CalculatePayLinesForDay is + // the single exit point and rewrites DEFAULT → NORMAL. + var payRuleSet = new PayRuleSet + { + Id = 9001, + Name = "WeekdayOnly", + DayRules = new List + { + new() + { + DayCode = "WEEKDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = null, PayCode = "NORMAL" }, + } + } + }, + DayTypeRules = new List(), + }; + + var lines = Run(Sunday, payRuleSet, SingleShift(Sunday, 6 * Hour, 6 * Hour), 6 * Hour); + + Assert.That(lines, Has.Count.EqualTo(1), "Expected exactly one fallback line"); + AssertPayLines(lines, 21600, ("NORMAL", 21600)); + Assert.That(lines.Any(l => l.PayCode == "DEFAULT"), Is.False, + "DEFAULT must never leak out of CalculatePayLinesForDay"); + } + + // ------------------------------------------------------------------ + // 8. Regression guards for the branches the fix deliberately left alone. + // ------------------------------------------------------------------ + + [Test] + public void Regression_BandedDayWithSingleTier_YieldsBandsOnly_NoOvertimeLines() + { + // Even on an OPTED-IN preset, the split needs a day rule that describes an + // overtime progression (>1 tier AND a non-null first UpToSeconds). With exactly + // ONE tier the historical bands-only behaviour must survive untouched. (What + // keeps the OTHER presets from changing is the name gate, not this check — see + // Gate_NonPraktikantBandedPresetWithMultipleTiers_StillTakesBandsOnlyPath.) + // 06:00 → 18:00 is attributed by clock position across the whole 12 h, with NO + // truncation at the norm: + // SAT_NORMAL 06:00 → 12:00 = 21600 s + // SAT_ANIMAL_AFTERNOON 12:00 → 18:00 = 21600 s + var payRuleSet = PraktikantFixtures.Staldarbejde(); + payRuleSet.DayRules.Single(r => r.DayCode == "SATURDAY").Tiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "SAT_NORMAL" }, + }; + + var lines = Run(Saturday, payRuleSet, SingleShift(Saturday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("SAT_NORMAL", 21600), + ("SAT_ANIMAL_AFTERNOON", 21600)); + Assert.That(lines.Any(l => l.PayCode!.StartsWith("OVERTIME")), Is.False, + "A single-tier banded day must not produce overtime lines"); + } + + [Test] + public void Regression_DayWithoutBands_YieldsTiersOnly() + { + // Staldarbejde declares day-type rules for Saturday/Sunday/Holiday only. On a + // Monday there is no band, so the tier path runs and no supplement code appears. + var lines = Run(Weekday, PraktikantFixtures.Staldarbejde(), + SingleShift(Weekday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("NORMAL", 26640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + Assert.That(lines.Any(l => l.PayCode == "SAT_NORMAL" + || l.PayCode == "SAT_ANIMAL_AFTERNOON" + || l.PayCode == "ANIMAL_SUN_HOLIDAY"), Is.False, + "A band-less day must not produce any clock-time supplement code"); + } + + [Test] + public void Regression_NullPayRuleSet_ReturnsEmpty() + { + var lines = TimePlanningWorkingHoursService.CalculatePayLinesForDay( + planRegistrationId: 1, date: Saturday, + dayModel: SingleShift(Saturday, 6 * Hour, 12 * Hour), + totalSeconds: 12 * Hour, payRuleSet: null!); + + Assert.That(lines, Is.Empty); + } + + [Test] + public void Regression_ZeroTotalSeconds_TierPath_ReturnsEmpty() + { + var lines = Run(Weekday, PraktikantFixtures.Staldarbejde(), + new TimePlanningWorkingHoursModel { Date = Weekday }, 0); + + Assert.That(lines, Is.Empty); + } + + [Test] + public void Regression_ZeroTotalSeconds_BandedPath_ReturnsEmpty() + { + // Saturday has bands AND a 3-tier day rule, so the split branch is entered, but + // bandSeconds = min(0, 26640) = 0 truncates every segment away and + // overtimeSeconds = max(0, 0 - 26640) = 0, so nothing is emitted. + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), + new TimePlanningWorkingHoursModel { Date = Saturday }, 0); + + Assert.That(lines, Is.Empty); + } + + // ------------------------------------------------------------------ + // 9. THE SCOPE GUARD — the split is opted into by rule-set NAME, not by shape. + // ------------------------------------------------------------------ + + [Test] + public void Gate_NonPraktikantBandedPresetWithMultipleTiers_StillTakesBandsOnlyPath() + { + // REGRESSION GUARD FOR THE SCOPE BREACH. This rule set has exactly the shape the + // split used to key off — Saturday time bands AND a >1-tier Saturday day rule + // whose tier 1 has a non-null UpToSeconds — but it is NOT a praktikant preset. + // It resembles Jordbrug Standard, where tier 1 is a deliberate MIRROR of the + // clock split rather than a normal-time boundary. Thirteen preset/day + // combinations across Jordbrug, Gartneri, Skovbrug and KA share that shape and + // are explicitly out of scope in the 2026-08-07 spec. + // + // The work is 04:36 → 12:00 = 26640 s, a MORNING-ONLY Saturday. + // Correct (bands-only): the whole 26640 s is inside the 00:00–12:00 band → + // SAT_NORMAL 26640, nothing else. + // What the shape gate produced: bandSeconds = min(26640, 21600) = 21600, so + // the segment was truncated to 04:36 → 10:36 (SAT_NORMAL 21600) and the + // remaining 5040 s fell through to tier 2 → SAT_AFTERNOON 5040 — an + // afternoon supplement on a worker who went home at noon. + var payRuleSet = new PayRuleSet + { + Id = 7001, + Name = "GLS-A / 3F - Jordbrug Standard 2026-2029", + DayRules = new List + { + new() + { + DayCode = "SATURDAY", + Tiers = new List + { + new() { Order = 1, UpToSeconds = 21600, PayCode = "SAT_NORMAL" }, + new() { Order = 2, UpToSeconds = null, PayCode = "SAT_AFTERNOON" }, + } + } + }, + DayTypeRules = new List + { + new() + { + DayType = DayType.Saturday, + DefaultPayCode = "SAT_NORMAL", + Priority = 1, + TimeBandRules = new List + { + new() { StartSecondOfDay = 0, EndSecondOfDay = 43200, PayCode = "SAT_NORMAL", Priority = 1 }, + new() { StartSecondOfDay = 43200, EndSecondOfDay = 86400, PayCode = "SAT_AFTERNOON", Priority = 1 }, + } + } + }, + }; + + var lines = Run(Saturday, payRuleSet, SingleShift(Saturday, 43200 - Norm, Norm), Norm); + + AssertPayLines(lines, 26640, ("SAT_NORMAL", 26640)); + Assert.That(lines.Any(l => l.PayCode == "SAT_AFTERNOON"), Is.False, + "A morning-only Saturday must never earn the afternoon supplement"); + Assert.That(lines.Any(l => l.PayCode!.StartsWith("OVERTIME")), Is.False, + "A non-opted-in preset must not produce overtime lines from its mirror tiers"); + } + + [Test] + public void Gate_PraktikantPresetWithLegacyValidityPeriod_StillTakesSplitPath() + { + // Customer rows may still carry the previous agreement period in their name. The + // gate normalizes the trailing " YYYY-YYYY" away (PayRuleSetLock.NormalizePresetName), + // so a "… 2024-2026" row behaves exactly like the "… 2026-2029" catalogue entry. + // Expectations are identical to Stald_Saturday_12h_BandsUntilNorm_ThenOvertimeTiers. + var payRuleSet = PraktikantFixtures.Staldarbejde(); + payRuleSet.Name = "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde 2024-2026"; + + var lines = Run(Saturday, payRuleSet, SingleShift(Saturday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, + ("SAT_NORMAL", 21600), + ("SAT_ANIMAL_AFTERNOON", 5040), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 9360)); + } + + // ------------------------------------------------------------------ + // 10. PAUSES must not consume the normal-time budget. + // ------------------------------------------------------------------ + + [Test] + public void Stald_Saturday_PauseInsideNormalTime_DoesNotShiftTheBoundary() + { + // Shift 06:00 → 18:00 with a 11:00 → 12:00 pause. + // + // NOTE THE TOTAL. totalSeconds is NETTO — every caller computes it as + // nettoHours * 3600 — so a 12 h span with a 1 h pause is 39600 s of work, not + // 43200. (The review note quoted 43200-style expectations; the conservation + // helper would reject them, because only 39600 s were actually worked.) + // + // Worked segments (pauses cut out, real clock positions kept): + // 06:00 → 11:00 = 18000 s + // 12:00 → 18:00 = 21600 s + // Normal time = min(39600, 26640) = 26640 → consumes segment 1 whole (18000, + // remaining 8640) then splits segment 2 at 12:00 + 8640 s = 14:24. + // SAT_NORMAL 06:00 → 11:00 = 18000 s + // SAT_ANIMAL_AFTERNOON 12:00 → 14:24 = 8640 s (18000 + 8640 = 26640 ✓) + // Overtime = 39600 - 26640 = 12960 → OVERTIME_50 7200, OVERTIME_80 5760. + // + // BEFORE THE FIX the budget was consumed against GROSS segments, so the pause hour + // was charged to the normal-time budget: the boundary landed at 13:24 instead of + // 14:24 and the worker lost a full hour of afternoon supplement + // (SAT_NORMAL 21600 / SAT_ANIMAL_AFTERNOON 5040). + var model = new TimePlanningWorkingHoursModel + { + Date = Saturday, + Start1StartedAt = Saturday.AddHours(6), + Stop1StoppedAt = Saturday.AddHours(18), + Pause1StartedAt = Saturday.AddHours(11), + Pause1StoppedAt = Saturday.AddHours(12), + }; + + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), model, 39600); + + AssertPayLines(lines, 39600, + ("SAT_NORMAL", 18000), + ("SAT_ANIMAL_AFTERNOON", 8640), + ("OVERTIME_50", 7200), + ("OVERTIME_80", 5760)); + } + + [Test] + public void Stald_Saturday_PauseSpanningNoon_PauseSecondsAreNeverAttributed() + { + // Shift 08:00 → 16:00 with a 11:30 → 12:30 pause that STRADDLES the band + // boundary. Netto = 28800 - 3600 = 25200 s, below the norm, so nothing is + // overtime and every worked second is band-attributed by clock position: + // 08:00 → 11:30 = 12600 s → SAT_NORMAL (before noon) + // 12:30 → 16:00 = 12600 s → SAT_ANIMAL_AFTERNOON (after noon) + // The 30 min of pause before noon and 30 min after are attributed to NEITHER. + var model = new TimePlanningWorkingHoursModel + { + Date = Saturday, + Start1StartedAt = Saturday.AddHours(8), + Stop1StoppedAt = Saturday.AddHours(16), + Pause1StartedAt = Saturday.AddHours(11).AddMinutes(30), + Pause1StoppedAt = Saturday.AddHours(12).AddMinutes(30), + }; + + var lines = Run(Saturday, PraktikantFixtures.Staldarbejde(), model, 25200); + + AssertPayLines(lines, 25200, + ("SAT_NORMAL", 12600), + ("SAT_ANIMAL_AFTERNOON", 12600)); + } + + [Test] + public void Stald_Grundlovsdag_PauseAcrossNoon_NoonSplitUsesWorkedSecondsOnly() + { + // The Grundlovsdag noon split rides on the same truncation, so it inherits the + // pause awareness. 08:00 → 15:00 with a 11:30 → 12:30 pause = 21600 s netto, + // below the norm: + // 08:00 → 11:30 = 12600 s → NORMAL + // 12:30 → 15:00 = 9000 s → ANIMAL_SUN_HOLIDAY + var model = new TimePlanningWorkingHoursModel + { + Date = Grundlovsdag, + Start1StartedAt = Grundlovsdag.AddHours(8), + Stop1StoppedAt = Grundlovsdag.AddHours(15), + Pause1StartedAt = Grundlovsdag.AddHours(11).AddMinutes(30), + Pause1StoppedAt = Grundlovsdag.AddHours(12).AddMinutes(30), + }; + + var lines = Run(Grundlovsdag, PraktikantFixtures.Staldarbejde(), model, 21600); + + AssertPayLines(lines, 21600, + ("NORMAL", 12600), + ("ANIMAL_SUN_HOLIDAY", 9000)); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static List Run( + DateTime date, PayRuleSet payRuleSet, TimePlanningWorkingHoursModel model, int totalSeconds) + => TimePlanningWorkingHoursService.CalculatePayLinesForDay( + planRegistrationId: 1, date: date, dayModel: model, + totalSeconds: totalSeconds, payRuleSet: payRuleSet); + + /// + /// Builds a day model with a single populated shift starting at + /// and running for . + /// Only Start{N}StartedAt / Stop{N}StoppedAt are read by EnumerateShiftSegments — + /// the legacy 5-minute slot fields are deliberately left unset. + /// + private static TimePlanningWorkingHoursModel SingleShift(DateTime date, int startSecondOfDay, int seconds) + => new() + { + Date = date, + Start1StartedAt = date.AddSeconds(startSecondOfDay), + Stop1StoppedAt = date.AddSeconds(startSecondOfDay + seconds), + }; + + private static void RunWeekdayBoundaryCase( + PayRuleSet payRuleSet, int totalSeconds, params (string PayCode, int Seconds)[] expected) + { + var lines = Run(Weekday, payRuleSet, SingleShift(Weekday, 6 * Hour, totalSeconds), totalSeconds); + AssertPayLines(lines, totalSeconds, expected); + } + + /// + /// Asserts the exact seconds per pay code AND the conservation invariant: the sum of + /// HoursInSeconds across every returned line equals , + /// so no minute is lost or double-counted. Also cross-checks that the per-code + /// expectations themselves sum to that total, which catches arithmetic slips in the + /// test data rather than letting them mask an engine bug. + /// + private static void AssertPayLines( + IReadOnlyList lines, + int expectedTotalSeconds, + params (string PayCode, int Seconds)[] expected) + { + Assert.That(expected.Sum(e => e.Seconds), Is.EqualTo(expectedTotalSeconds), + "Test data is self-inconsistent: the expected per-code seconds do not sum to the expected total"); + + Assert.That(lines.Select(l => l.PayCode).Distinct().Count(), Is.EqualTo(lines.Count), + "Pay lines must be merged by pay code — no duplicate codes"); + + Assert.That(lines.Select(l => l.PayCode), Is.EquivalentTo(expected.Select(e => e.PayCode)), + "Unexpected set of pay codes: " + string.Join(", ", + lines.Select(l => $"{l.PayCode}={l.HoursInSeconds}"))); + + foreach (var (payCode, seconds) in expected) + { + var line = lines.Single(l => l.PayCode == payCode); + Assert.That(line.HoursInSeconds, Is.EqualTo(seconds), $"{payCode} seconds"); + Assert.That(line.Hours, Is.EqualTo(seconds / 3600.0).Within(0.0001), $"{payCode} hours"); + } + + Assert.That(lines.Sum(l => l.HoursInSeconds), Is.EqualTo(expectedTotalSeconds), + "Conservation invariant: attributed seconds must equal worked seconds exactly"); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs index 8bbc249b..f639ae2c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs @@ -21,6 +21,10 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; /// bypassable. Hoisting the logic here lets every service that can mutate a /// locked preset (directly or through a child row) share the exact same /// definition. +/// +/// It is also the home for the other preset-IDENTITY questions the engine has to ask, +/// because they all need the same validity-period normalization — see +/// . /// internal static class PayRuleSetLock { @@ -105,4 +109,55 @@ internal static bool IsLockedPresetName(string name) var normalized = NormalizePresetName(name); return normalized.Length > 0 && NormalizedLockedPresetNames.Contains(normalized); } + + // ------------------------------------------------------------------ + // Normal-time / overtime split — OPT-IN BY RULE-SET IDENTITY + // ------------------------------------------------------------------ + + /// + /// The presets whose day rules encode tier 1 as a NORMAL-TIME BOUNDARY, and which + /// therefore opt in to the sequential normal-time/overtime split in + /// TimePlanningWorkingHoursService.CalculatePayLinesForDay (and to the + /// Grundlovsdag noon split that rides on the same interpretation). + /// + /// WHY THIS IS GATED ON IDENTITY AND NOT ON RULE-SET SHAPE + /// ------------------------------------------------------ + /// The split reinterprets the first tier of a banded day as "normal time ends here; + /// everything past it is overtime and carries no clock-time supplement". That reading + /// is only true for these two § 50 praktikant presets, where § 50 stk. 4 d pays the + /// supplements exclusively "for arbejde i normal arbejdstid". + /// + /// The shape the split used to key off — "the day has time bands AND the day rule has + /// more than one tier AND tier 1 has a non-null UpToSeconds" — is NOT unique to them. + /// Thirteen other preset/day combinations match it (Jordbrug Standard and Jordbrug + /// Dyrehold WEEKDAY+SATURDAY, Gartneri Standard WEEKDAY+SATURDAY, Skovbrug Standard + /// WEEKDAY+SATURDAY, KA Svine/Plante/Maskin WEEKDAY, KA Gron WEEKDAY+SATURDAY). In + /// those presets tier 1 is a deliberate MIRROR of the clock split, not a normal-time + /// boundary, so applying the split there would hand a morning-only Saturday an + /// afternoon supplement it never earned. Those presets are explicitly out of scope in + /// the 2026-08-07 spec, so the opt-in is by name. + /// + /// A per-rule-set database column would be the better home for this flag, but the + /// entity lives in the eform-timeplanning-base NuGet package and cannot be extended + /// from the plugin. Name matching mirrors the locked-preset mechanism above, and goes + /// through so a customer row still carrying the + /// "… 2024-2026" validity period matches the "… 2026-2029" catalogue entry. + /// + private static readonly HashSet NormalizedNormalTimeSplitPresetNames = + new HashSet(new[] + { + "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde", + "GLS-A / 3F - Udenlandske praktikanter Landbrug Andet arbejde" + }.Select(NormalizePresetName)); + + /// + /// True when the rule set opts in to the sequential normal-time/overtime split. + /// See for why this is an + /// identity check and not a shape check. + /// + internal static bool IsNormalTimeSplitPresetName(string name) + { + var normalized = NormalizePresetName(name); + return normalized.Length > 0 && NormalizedNormalTimeSplitPresetNames.Contains(normalized); + } } 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 671ff339..1ab1c7f3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -4133,43 +4133,71 @@ double CalculateOverlap(DateTime? start, DateTime? stop) totalSecondsAfterNoon += CalculateOverlap(day.Start4StartedAt, day.Stop4StoppedAt); totalSecondsAfterNoon += CalculateOverlap(day.Start5StartedAt, day.Stop5StoppedAt); - // Subtract pauses that occur after noon - totalSecondsAfterNoon -= CalculateOverlap(day.Pause1StartedAt, day.Pause1StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause2StartedAt, day.Pause2StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause3StartedAt, day.Pause3StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause4StartedAt, day.Pause4StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause5StartedAt, day.Pause5StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause10StartedAt, day.Pause10StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause11StartedAt, day.Pause11StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause12StartedAt, day.Pause12StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause13StartedAt, day.Pause13StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause14StartedAt, day.Pause14StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause15StartedAt, day.Pause15StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause16StartedAt, day.Pause16StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause17StartedAt, day.Pause17StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause18StartedAt, day.Pause18StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause19StartedAt, day.Pause19StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause20StartedAt, day.Pause20StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause21StartedAt, day.Pause21StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause22StartedAt, day.Pause22StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause23StartedAt, day.Pause23StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause24StartedAt, day.Pause24StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause25StartedAt, day.Pause25StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause26StartedAt, day.Pause26StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause27StartedAt, day.Pause27StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause28StartedAt, day.Pause28StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause29StartedAt, day.Pause29StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause100StartedAt, day.Pause100StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause101StartedAt, day.Pause101StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause102StartedAt, day.Pause102StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause200StartedAt, day.Pause200StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause201StartedAt, day.Pause201StoppedAt); - totalSecondsAfterNoon -= CalculateOverlap(day.Pause202StartedAt, day.Pause202StoppedAt); + // Subtract pauses that occur after noon. EnumeratePauseIntervals is the shared + // source of truth for "which fields hold a pause", so this method and the + // pause-aware pay-line segmentation (EnumerateWorkedSegments) can never drift + // apart on, say, a newly added sub-slot. + foreach (var (pauseStart, pauseStop) in EnumeratePauseIntervals(day)) + { + totalSecondsAfterNoon -= CalculateOverlap(pauseStart, pauseStop); + } // Convert to hours and ensure non-negative return Math.Max(0, totalSecondsAfterNoon / 3600.0); } + /// + /// The single source of truth for WHICH model fields hold a pause interval. + /// + /// Layout (all real wall-clock stamps, mirroring the shift Start/Stop stamps): + /// - Pause1..Pause5 — the primary pause of shifts 1..5; + /// - Pause10..Pause19 — shift 1's additional sub-slots; + /// - Pause20..Pause29 — shift 2's additional sub-slots; + /// - Pause100..Pause102 and Pause200..Pause202 — legacy extra sub-slots for + /// shifts 1 and 2 respectively. + /// + /// Yields the raw nullable pairs; callers decide how to interpret unset or + /// non-positive intervals. Both consumers — Grundlovsdag holiday math + /// () and pay-line segmentation + /// () — read this list, so they agree by + /// construction. + /// + internal static IEnumerable<(DateTime? Start, DateTime? Stop)> EnumeratePauseIntervals( + TimePlanningWorkingHoursModel day) + { + yield return (day.Pause1StartedAt, day.Pause1StoppedAt); + yield return (day.Pause2StartedAt, day.Pause2StoppedAt); + yield return (day.Pause3StartedAt, day.Pause3StoppedAt); + yield return (day.Pause4StartedAt, day.Pause4StoppedAt); + yield return (day.Pause5StartedAt, day.Pause5StoppedAt); + yield return (day.Pause10StartedAt, day.Pause10StoppedAt); + yield return (day.Pause11StartedAt, day.Pause11StoppedAt); + yield return (day.Pause12StartedAt, day.Pause12StoppedAt); + yield return (day.Pause13StartedAt, day.Pause13StoppedAt); + yield return (day.Pause14StartedAt, day.Pause14StoppedAt); + yield return (day.Pause15StartedAt, day.Pause15StoppedAt); + yield return (day.Pause16StartedAt, day.Pause16StoppedAt); + yield return (day.Pause17StartedAt, day.Pause17StoppedAt); + yield return (day.Pause18StartedAt, day.Pause18StoppedAt); + yield return (day.Pause19StartedAt, day.Pause19StoppedAt); + yield return (day.Pause20StartedAt, day.Pause20StoppedAt); + yield return (day.Pause21StartedAt, day.Pause21StoppedAt); + yield return (day.Pause22StartedAt, day.Pause22StoppedAt); + yield return (day.Pause23StartedAt, day.Pause23StoppedAt); + yield return (day.Pause24StartedAt, day.Pause24StoppedAt); + yield return (day.Pause25StartedAt, day.Pause25StoppedAt); + yield return (day.Pause26StartedAt, day.Pause26StoppedAt); + yield return (day.Pause27StartedAt, day.Pause27StoppedAt); + yield return (day.Pause28StartedAt, day.Pause28StoppedAt); + yield return (day.Pause29StartedAt, day.Pause29StoppedAt); + yield return (day.Pause100StartedAt, day.Pause100StoppedAt); + yield return (day.Pause101StartedAt, day.Pause101StoppedAt); + yield return (day.Pause102StartedAt, day.Pause102StoppedAt); + yield return (day.Pause200StartedAt, day.Pause200StoppedAt); + yield return (day.Pause201StartedAt, day.Pause201StoppedAt); + yield return (day.Pause202StartedAt, day.Pause202StoppedAt); + } + private string GetColumnLetter(int columnIndex) { string columnLetter = ""; @@ -4665,8 +4693,28 @@ internal static List CalculatePayLinesForDay( return new List(); } + var calculatedAtUtc = DateTime.UtcNow; var dayCode = GetDayCodeForDate(date); + // Does this rule set opt in to the sequential normal-time/overtime reading of + // tier 1? Identity, not shape — see PayRuleSetLock.IsNormalTimeSplitPresetName + // for why the shape test was wrong and which presets it wrongly caught. + var usesNormalTimeSplit = PayRuleSetLock.IsNormalTimeSplitPresetName(payRuleSet.Name); + + // Grundlovsdag noon split. TryGetDayType returns false for GRUNDLOVSDAG, so the + // day has no DayType to hang time bands on and this cannot be expressed in the + // preset data — it is implemented here instead. + if (usesNormalTimeSplit && dayCode == "GRUNDLOVSDAG") + { + var grundlovsdagLines = CalculateGrundlovsdagPayLines( + planRegistrationId, dayModel, totalSeconds, payRuleSet, calculatedAtUtc); + + if (grundlovsdagLines != null) + { + return MapDefaultToNormal(MergeByPayCode(grundlovsdagLines)); + } + } + // Time-band path: if PayRuleSet defines time-band rules for the day type, use them. if (TryGetDayType(date, dayCode, out var dayType)) { @@ -4677,13 +4725,66 @@ internal static List CalculatePayLinesForDay( if (hasTimeBandRule) { + // WHY the split below: clock-time supplements (Saturday-afternoon + // animal care, Sunday/holiday) are payable per § 50 stk. 4 d only + // "For arbejde I NORMAL ARBEJDSTID". Minutes worked beyond the daily + // norm are overtime and are paid by the overtime steps INSTEAD of the + // supplement — never both. So normal time is attributed by clock + // position (bands) and the overflow purely by duration (tiers 2..n). + // + // Before this, the routing was exclusive: any band for the day meant + // the tiers never ran and every overtime minute was silently lost. + var orderedTiers = payRuleSet.DayRules? + .SingleOrDefault(dr => dr.DayCode == dayCode)? + .Tiers? + .OrderBy(t => t.Order) + .ToList(); + + // The split is OPT-IN BY RULE-SET IDENTITY (usesNormalTimeSplit), not by + // rule-set shape. Reinterpreting tier 1 as a normal-time boundary is only + // correct for the two § 50 praktikant presets; thirteen other preset/day + // combinations have the same shape but encode tier 1 as a MIRROR of their + // clock split, and reinterpreting those would award afternoon supplements + // to morning-only Saturdays. See PayRuleSetLock.IsNormalTimeSplitPresetName. + // + // Every other rule set — and any opted-in day whose rule lacks a real + // overtime progression (>1 tier with an explicit boundary on tier 1) — + // falls through to the historical bands-only behaviour below, unchanged. + if (usesNormalTimeSplit + && orderedTiers is { Count: > 1 } + && orderedTiers[0].UpToSeconds is { } normalSeconds) + { + var bandSeconds = Math.Min(totalSeconds, normalSeconds); + var overtimeSeconds = Math.Max(0, totalSeconds - normalSeconds); + + var splitResults = new List(); + + // Normal time keeps its clock position — the segments are + // truncated, not rescaled, so e.g. a Saturday shift straddling + // 12:00 still splits at 12:00. + foreach (var (start, stop) in EnumerateShiftSegmentsTruncated(dayModel, bandSeconds)) + { + splitResults.AddRange(PayLineGenerator.GenerateTimeBandPayLines( + planRegistrationId, dayType, start, stop, payRuleSet, calculatedAtUtc)); + } + + if (overtimeSeconds > 0) + { + splitResults.AddRange(GenerateOvertimeTierPayLines( + planRegistrationId, orderedTiers, normalSeconds, overtimeSeconds, + payRuleSet, calculatedAtUtc)); + } + + return MapDefaultToNormal(MergeByPayCode(splitResults)); + } + var bandResults = new List(); foreach (var (start, stop) in EnumerateShiftSegments(dayModel)) { bandResults.AddRange(PayLineGenerator.GenerateTimeBandPayLines( - planRegistrationId, dayType, start, stop, payRuleSet, DateTime.UtcNow)); + planRegistrationId, dayType, start, stop, payRuleSet, calculatedAtUtc)); } - return MergeByPayCode(bandResults); + return MapDefaultToNormal(MergeByPayCode(bandResults)); } } @@ -4693,7 +4794,379 @@ internal static List CalculatePayLinesForDay( return new List(); } - return PayLineGenerator.GeneratePayLines( - planRegistrationId, dayCode, totalSeconds, payRuleSet, DateTime.UtcNow); + return MapDefaultToNormal(PayLineGenerator.GeneratePayLines( + planRegistrationId, dayCode, totalSeconds, payRuleSet, calculatedAtUtc)); + } + + /// + /// The pause intervals of the day as non-overlapping, ascending seconds-of-day + /// ranges. Built from (the shared field list) + /// and resolved with the same rules as the shifts, + /// so a pause running past midnight is clamped to end-of-day exactly as a shift is. + /// + /// Overlapping or duplicated pause rows are merged, so a second that is covered by + /// two pause slots is only ever removed once. + /// + private static List<(int Start, int Stop)> BuildMergedPauseSegments( + TimePlanningWorkingHoursModel dayModel) + { + var pauses = new List<(int Start, int Stop)>(); + + foreach (var (start, stop) in EnumeratePauseIntervals(dayModel)) + { + var resolved = ResolveShiftSeconds(start, stop); + if (resolved.HasValue) + { + pauses.Add(resolved.Value); + } + } + + if (pauses.Count <= 1) + { + return pauses; + } + + pauses.Sort((a, b) => a.Start.CompareTo(b.Start)); + + var merged = new List<(int Start, int Stop)> { pauses[0] }; + for (var i = 1; i < pauses.Count; i++) + { + var last = merged[^1]; + var current = pauses[i]; + + if (current.Start <= last.Stop) + { + merged[^1] = (last.Start, Math.Max(last.Stop, current.Stop)); + } + else + { + merged.Add(current); + } + } + + return merged; + } + + /// + /// Yields the WORKED (netto) portions of the day as seconds-of-day ranges: the shift + /// segments of with every pause interval cut out. + /// + /// WHY this exists: shift segments are GROSS (pauses included) while every seconds + /// budget in the pay engine — totalSeconds comes straight from netto hours — is + /// NETTO. Consuming a netto budget against gross segments charges pause time to the + /// budget, which pulls the normal-time boundary earlier in the day and silently steals + /// afternoon-supplement minutes from the worker. + /// + /// The emitted ranges keep their real clock positions, so band attribution is + /// unaffected; only the pause seconds are removed. + /// + internal static IEnumerable<(int Start, int Stop)> EnumerateWorkedSegments( + TimePlanningWorkingHoursModel dayModel) + { + var pauses = BuildMergedPauseSegments(dayModel); + + foreach (var (start, stop) in EnumerateShiftSegments(dayModel)) + { + if (pauses.Count == 0) + { + yield return (start, stop); + continue; + } + + var cursor = start; + + foreach (var (pauseStart, pauseStop) in pauses) + { + if (pauseStop <= cursor) + { + continue; + } + + if (pauseStart >= stop) + { + break; + } + + if (pauseStart > cursor) + { + yield return (cursor, pauseStart); + } + + cursor = pauseStop; + + if (cursor >= stop) + { + break; + } + } + + if (cursor < stop) + { + yield return (cursor, stop); + } + } + } + + /// + /// Walks in order and yields only the first + /// of WORKED time. The segment that crosses the + /// budget boundary is split (its stop is pulled back); later segments are dropped. + /// + /// WHY truncate rather than scale: the surviving seconds must keep their real + /// clock position, because the time bands they are attributed against are defined + /// in seconds-of-day (e.g. the Saturday 12:00 animal-care boundary). + /// + /// WHY worked and not gross segments: the budget handed in is derived from netto + /// hours, so pause seconds must not consume it — see + /// . Pause time is never emitted, so it can + /// never be band-attributed as if it had been worked either. + /// + internal static IEnumerable<(int Start, int Stop)> EnumerateShiftSegmentsTruncated( + TimePlanningWorkingHoursModel dayModel, + int budgetSeconds) + { + var remaining = budgetSeconds; + + foreach (var (start, stop) in EnumerateWorkedSegments(dayModel)) + { + if (remaining <= 0) + { + yield break; + } + + var duration = stop - start; + if (duration <= remaining) + { + remaining -= duration; + yield return (start, stop); + } + else + { + yield return (start, start + remaining); + yield break; + } + } + } + + /// 12:00 as a second-of-day — the Grundlovsdag half-day boundary (§ 29). + private const int NoonSecondOfDay = 43200; + + /// + /// Grundlovsdag (5 June) for the opted-in § 50 praktikant presets. + /// + /// § 29 makes it a half day: minutes before 12:00 are ordinary working time, minutes + /// from 12:00 follow søgnehelligdag treatment. GRUNDLOVSDAG has no + /// ( returns false), so the split + /// cannot be expressed as preset time bands and is applied here instead. + /// + /// PRECEDENCE — the two rules do not conflict, they compose, in this order: + /// + /// 1. THE NORMAL-TIME BOUNDARY WINS FIRST AND ABSOLUTELY. Only the first + /// tier 1 UpToSeconds (26640 s) of WORKED time is eligible for any + /// day-classification code at all. Everything beyond it is overtime and goes to + /// the GRUNDLOVSDAG tiers 2..n — exactly as on a banded day. Overtime minutes + /// never carry NORMAL nor the søgnehelligdag code, which is the same + /// "supplements only for arbejde i normal arbejdstid" rule as § 50 stk. 4 d. + /// + /// 2. WITHIN those normal-time seconds, the 12:00 split decides the code by CLOCK + /// POSITION: before noon → tier 1's pay code (ordinary working time, "NORMAL"); + /// from noon → the preset's søgnehelligdag treatment. + /// + /// 3. "SØGNEHELLIGDAG TREATMENT" IS READ FROM THE PRESET'S OWN SUNDAY RULE rather + /// than hard-coded, because that is precisely what the phrase means — treat the + /// minute as if the day were a Sunday. Staldarbejde has an all-day Sunday band, so + /// afternoon minutes become ANIMAL_SUN_HOLIDAY. Andet arbejde has no Sunday bands, + /// so its SUNDAY tier ladder runs (first 7200 s OVERTIME_50, remainder + /// OVERTIME_80) over the afternoon normal-time seconds only, restarting at zero. + /// + /// Andet arbejde is the case worth spelling out: its afternoon ladder and the daily + /// overtime overflow both emit OVERTIME_50/OVERTIME_80. That is addition, not a + /// conflict — the two minute sets are disjoint by construction (step 1 partitions the + /// day before step 2 ever runs), so MergeByPayCode simply sums them and the + /// conservation invariant still holds. + /// + /// Returns null when the preset's GRUNDLOVSDAG rule does not describe a normal-time + /// boundary followed by an overtime progression; the caller then falls through to the + /// historical routing rather than inventing a boundary. + /// + private static List? CalculateGrundlovsdagPayLines( + int planRegistrationId, + TimePlanningWorkingHoursModel dayModel, + int totalSeconds, + PayRuleSet payRuleSet, + DateTime calculatedAtUtc) + { + var orderedTiers = payRuleSet.DayRules? + .SingleOrDefault(dr => dr.DayCode == "GRUNDLOVSDAG")? + .Tiers? + .OrderBy(t => t.Order) + .ToList(); + + if (orderedTiers is not { Count: > 1 } + || orderedTiers[0].UpToSeconds is not { } normalSeconds) + { + return null; + } + + var result = new List(); + + // Step 1: partition the day at the normal-time boundary. + var normalTimeSeconds = Math.Min(totalSeconds, normalSeconds); + var overtimeSeconds = Math.Max(0, totalSeconds - normalSeconds); + + // Step 2: split the normal-time seconds at 12:00 by real clock position. + var morningTier = orderedTiers[0]; + var afternoonSegments = new List<(int Start, int Stop)>(); + + foreach (var (start, stop) in EnumerateShiftSegmentsTruncated(dayModel, normalTimeSeconds)) + { + var morningStop = Math.Min(stop, NoonSecondOfDay); + if (morningStop > start) + { + result.Add(new PlanRegistrationPayLine + { + PlanRegistrationId = planRegistrationId, + PayCode = morningTier.PayCode, + PayrollCode = morningTier.PayrollCode, + HoursInSeconds = morningStop - start, + Hours = (morningStop - start) / 3600.0, + PayRuleSetId = payRuleSet.Id, + CalculatedAt = calculatedAtUtc + }); + } + + var afternoonStart = Math.Max(start, NoonSecondOfDay); + if (stop > afternoonStart) + { + afternoonSegments.Add((afternoonStart, stop)); + } + } + + // Step 3: the afternoon normal-time seconds get the preset's Sunday treatment. + var afternoonSeconds = afternoonSegments.Sum(s => s.Stop - s.Start); + if (afternoonSeconds > 0) + { + var hasSundayBands = payRuleSet.DayTypeRules? + .Any(r => r.DayType == DayType.Sunday + && r.TimeBandRules != null + && r.TimeBandRules.Any()) ?? false; + + if (hasSundayBands) + { + foreach (var (start, stop) in afternoonSegments) + { + result.AddRange(PayLineGenerator.GenerateTimeBandPayLines( + planRegistrationId, DayType.Sunday, start, stop, payRuleSet, calculatedAtUtc)); + } + } + else + { + result.AddRange(PayLineGenerator.GeneratePayLines( + planRegistrationId, "SUNDAY", afternoonSeconds, payRuleSet, calculatedAtUtc)); + } + } + + // Step 1 (continued): everything past the boundary is plain overtime. + if (overtimeSeconds > 0) + { + result.AddRange(GenerateOvertimeTierPayLines( + planRegistrationId, orderedTiers, normalSeconds, overtimeSeconds, + payRuleSet, calculatedAtUtc)); + } + + return result; + } + + /// + /// Attributes overtime seconds across the day rule's tiers FROM TIER 2 ONWARD, + /// with the same cumulative-threshold semantics as + /// : each tier absorbs up to + /// (UpToSeconds - cumulative) seconds, and a null UpToSeconds absorbs the rest. + /// + /// WHY here and not in the base generator: GeneratePayLines takes a whole + /// PayRuleSet plus a day code and always starts at tier 1, so it cannot be asked + /// for "tiers 2..n". Tier 1 is skipped because it has already been fully consumed + /// by the normal-time bands (this method is only called when worked time exceeds + /// the tier-1 boundary), hence starts at that + /// boundary. + /// + private static List GenerateOvertimeTierPayLines( + int planRegistrationId, + IReadOnlyList orderedTiers, + int cumulativeSeconds, + int overtimeSeconds, + PayRuleSet payRuleSet, + DateTime calculatedAtUtc) + { + var result = new List(); + var remainingSeconds = overtimeSeconds; + + for (var i = 1; i < orderedTiers.Count; i++) + { + if (remainingSeconds <= 0) + { + break; + } + + var tier = orderedTiers[i]; + + int tierSeconds; + if (tier.UpToSeconds.HasValue) + { + var tierCap = tier.UpToSeconds.Value - cumulativeSeconds; + tierSeconds = Math.Min(remainingSeconds, tierCap); + } + else + { + tierSeconds = remainingSeconds; + } + + if (tierSeconds > 0) + { + result.Add(new PlanRegistrationPayLine + { + PlanRegistrationId = planRegistrationId, + PayCode = tier.PayCode, + PayrollCode = tier.PayrollCode, + HoursInSeconds = tierSeconds, + Hours = tierSeconds / 3600.0, + PayRuleSetId = payRuleSet.Id, + CalculatedAt = calculatedAtUtc + }); + + remainingSeconds -= tierSeconds; + cumulativeSeconds += tierSeconds; + } + } + + return result; + } + + /// + /// Rewrites the base package's no-rule fallback pay code "DEFAULT" to "NORMAL". + /// + /// WHY here: emits "DEFAULT" when no PayDayRule / + /// PayDayTypeRule matches the day, but the agreed semantics are that unmatched + /// minutes are ordinary working time. The base package cannot be changed from this + /// dev mode, so CalculatePayLinesForDay — the single exit point for pay-line + /// calculation — applies the mapping. TODO: move this into PayLineGenerator at the + /// next base release and drop this helper. + /// Re-merges only when something was actually rewritten, so a day that produced + /// both "NORMAL" and "DEFAULT" lines does not end up with two "NORMAL" rows. + /// + private static List MapDefaultToNormal(List lines) + { + var rewrote = false; + + foreach (var line in lines) + { + if (line.PayCode == "DEFAULT") + { + line.PayCode = "NORMAL"; + rewrote = true; + } + } + + return rewrote ? MergeByPayCode(lines) : lines; } } \ No newline at end of file diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/pay-rule-sets/pay-rule-set-presets.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/pay-rule-sets/pay-rule-set-presets.ts index 7090a5e8..bb7d4191 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/models/pay-rule-sets/pay-rule-set-presets.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/pay-rule-sets/pay-rule-set-presets.ts @@ -1706,11 +1706,28 @@ export const PAY_RULE_SET_PRESETS: PayRuleSetPreset[] = [ { order: 2, upToSeconds: null, payCode: 'OVERTIME_80' }, ], }, + // Grundlovsdag (5 June) is a half day per Jordbrug § 29: ordinary working + // time until 12:00, søgnehelligdag from 12:00 (decision 4 of the + // 2026-08-07 spec). The engine has no DayType for Grundlovsdag + // (TryGetDayType returns false for it), so the noon split CANNOT be + // expressed as time bands here — it is applied in the plugin, in + // CalculatePayLinesForDay -> CalculateGrundlovsdagPayLines, for this + // preset by name. + // + // What the tiers below mean in that path: + // - tier 1's 26640 s is the normal-time boundary, and its pay code + // ('NORMAL') is what the BEFORE-NOON normal-time minutes get; + // - the AFTER-NOON normal-time minutes instead get this preset's + // søgnehelligdag treatment, which the engine reads off the SUNDAY rule + // above: first 7200 s OVERTIME_50, remainder OVERTIME_80; + // - tiers 2..3 attribute everything past the 26640 s boundary, so + // overtime minutes carry neither NORMAL nor the søgnehelligdag code. { dayCode: 'GRUNDLOVSDAG', payTierRules: [ - { order: 1, upToSeconds: 7200, payCode: 'OVERTIME_50' }, - { order: 2, upToSeconds: null, payCode: 'OVERTIME_80' }, + { order: 1, upToSeconds: 26640, payCode: 'NORMAL' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_50' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, ], }, ], @@ -1733,31 +1750,81 @@ export const PAY_RULE_SET_PRESETS: PayRuleSetPreset[] = [ { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, ], }, - // Saturday: fallback tier splits at 6h to match the 12:00 clock split - // used by the payDayTypeRule below (which drives calculation). + // Saturday / Sunday / Holiday tiers below are NOT a fallback - they set + // the normal-time boundary and the overtime steps beyond it. The stald + // supplements are payable per § 50 stk. 4 d only "for arbejde i normal + // arbejdstid", so the first 7h24m are attributed by the clock-time bands + // (payDayTypeRules further down) and every minute past that boundary goes + // to the overtime steps here instead of carrying a supplement code. The + // first tier's pay code is the one the bands would have produced anyway; + // it is what defines where normal time ends. + // + // This reading is NOT applied to every preset with the same shape. The + // engine opts in BY RULE-SET NAME (PayRuleSetLock.IsNormalTimeSplitPresetName), + // because in the Jordbrug/Gartneri/Skovbrug/KA presets the equivalent + // tier 1 is a deliberate MIRROR of their clock split rather than a + // normal-time boundary. Renaming this preset without updating that list + // silently reverts it to bands-only. { dayCode: 'SATURDAY', payTierRules: [ - { order: 1, upToSeconds: 21600, payCode: 'SAT_NORMAL' }, - { order: 2, upToSeconds: null, payCode: 'SAT_ANIMAL_AFTERNOON' }, + { order: 1, upToSeconds: 26640, payCode: 'SAT_NORMAL' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_50' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, ], }, { dayCode: 'SUNDAY', - payTierRules: [{ order: 1, upToSeconds: null, payCode: 'ANIMAL_SUN_HOLIDAY' }], + payTierRules: [ + { order: 1, upToSeconds: 26640, payCode: 'ANIMAL_SUN_HOLIDAY' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_50' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, + ], }, { dayCode: 'HOLIDAY', - payTierRules: [{ order: 1, upToSeconds: null, payCode: 'ANIMAL_SUN_HOLIDAY' }], + payTierRules: [ + { order: 1, upToSeconds: 26640, payCode: 'ANIMAL_SUN_HOLIDAY' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_50' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, + ], }, + // Grundlovsdag (5 June) is a half day per Jordbrug § 29: ordinary working + // time until 12:00, søgnehelligdag from 12:00 (decision 4 of the + // 2026-08-07 spec). The engine has no DayType for Grundlovsdag + // (TryGetDayType returns false for it), so the noon split CANNOT be + // expressed as time bands here — it is applied in the plugin, in + // CalculatePayLinesForDay -> CalculateGrundlovsdagPayLines, for this + // preset by name. + // + // What the tiers below mean in that path: + // - tier 1's 26640 s is the normal-time boundary, and its pay code + // ('NORMAL') is what the BEFORE-NOON normal-time minutes get; + // - the AFTER-NOON normal-time minutes instead get this preset's + // søgnehelligdag treatment, which the engine reads off the Sunday + // entry in payDayTypeRules below: ANIMAL_SUN_HOLIDAY; + // - tiers 2..3 attribute everything past the 26640 s boundary, so + // overtime minutes carry neither NORMAL nor ANIMAL_SUN_HOLIDAY. { dayCode: 'GRUNDLOVSDAG', - payTierRules: [{ order: 1, upToSeconds: null, payCode: 'ANIMAL_SUN_HOLIDAY' }], + payTierRules: [ + { order: 1, upToSeconds: 26640, payCode: 'NORMAL' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_50' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, + ], }, ], payDayTypeRules: [ - // Saturday supplement kicks in from 12:00 (kr/dag fixed rate applies - // for hours after noon per the loenoversigt). + // These bands split the NORMAL-TIME portion of the day only (the engine + // truncates the worked PAUSE-FREE segments at the first tier's boundary + // before applying them — pauses do not consume the normal-time budget). + // That truncation happens because this preset is opted in by name; for + // every other preset the bands still cover the whole day as before. + // Saturday supplement kicks in from 12:00 (kr/dag fixed rate applies for + // hours after noon per the loenoversigt). + // + // The Sunday entry does double duty: it is also what the plugin reads as + // this preset's "søgnehelligdag treatment" for Grundlovsdag afternoons. { dayType: 'Saturday', defaultPayCode: 'SAT_NORMAL',