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',