diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs index 3910b2f8..9292ead1 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/Helpers/PraktikantFixtures.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Microting.TimePlanningBase.Infrastructure.Data.Entities; namespace TimePlanning.Pn.Test.Helpers; @@ -202,4 +203,94 @@ internal static class PraktikantFixtures }, DayTypeRules = new List(), }; + + // ================================================================== + // PRE-CORRECTION SNAPSHOTS — what EXISTING customer databases hold + // ================================================================== + // + // Preset definitions are COPIED INTO the customer's database when the rule set is + // created; they are not a live reference to the catalogue. Every customer who created + // a praktikant rule set BEFORE the tiers above were corrected therefore still holds + // the OLD tier rows — under the very same, unchanged Name. + // + // That matters because the normal-time/overtime split and the Grundlovsdag noon split + // are opted into BY NAME. The name still matches on these stale rows, so without a + // shape guard the engine would reinterpret tiers that do not encode a normal-time + // boundary at all: the old Staldarbejde SATURDAY tier 1 (21600 s) is a MIRROR of the + // 12:00 clock band, and tier 2's SAT_ANIMAL_AFTERNOON is a fixed kr/dag afternoon + // supplement — so the overflow would pay an afternoon supplement to a worker who went + // home at noon. + // + // PayRuleSetLock.HasNormalTimeBoundaryShape keeps these rows on the historical path. + // The fixtures below exist to prove that, and should be DELETED once a data migration + // has rewritten the stale rows to the corrected tiers. + // + // Only the day rules listed in the bug report differ from the corrected fixtures; the + // WEEKDAY rule and all time bands were never part of the correction and are inherited + // unchanged, so the fixtures are built by mutating the corrected ones — that keeps the + // delta visible and honours the three-way sync note above. + + /// Old Staldarbejde SATURDAY tier 1 cutoff: 06:00, mirroring the 12:00 band from a 06:00 start. + internal const int LegacySaturdayMirrorSeconds = 21600; + + /// Old Andet arbejde GRUNDLOVSDAG tier 1 cutoff: the 2 h Sunday-ladder step. + internal const int LegacyGrundlovsdagFirstStepSeconds = 7200; + + /// + /// as it exists in customer databases created BEFORE the + /// tier correction. Same Name (so the name gate still matches), but: + /// SATURDAY [21600 SAT_NORMAL, null SAT_ANIMAL_AFTERNOON] (2 tiers) + /// SUNDAY / HOLIDAY [null ANIMAL_SUN_HOLIDAY] (1 tier) + /// GRUNDLOVSDAG [null ANIMAL_SUN_HOLIDAY] (1 tier) + /// None of these encodes a normal-time boundary, so all of them must keep the + /// historical bands-only / pure-tier routing. + /// + internal static PayRuleSet StaldarbejdeLegacyTiers() + { + var payRuleSet = Staldarbejde(); + + payRuleSet.DayRules.Single(r => r.DayCode == "SATURDAY").Tiers = new List + { + new() { Order = 1, UpToSeconds = LegacySaturdayMirrorSeconds, PayCode = "SAT_NORMAL" }, + new() { Order = 2, UpToSeconds = null, PayCode = "SAT_ANIMAL_AFTERNOON" }, + }; + + payRuleSet.DayRules.Single(r => r.DayCode == "SUNDAY").Tiers = new List + { + new() { Order = 1, UpToSeconds = null, PayCode = "ANIMAL_SUN_HOLIDAY" }, + }; + + payRuleSet.DayRules.Single(r => r.DayCode == "HOLIDAY").Tiers = new List + { + new() { Order = 1, UpToSeconds = null, PayCode = "ANIMAL_SUN_HOLIDAY" }, + }; + + payRuleSet.DayRules.Single(r => r.DayCode == "GRUNDLOVSDAG").Tiers = new List + { + new() { Order = 1, UpToSeconds = null, PayCode = "ANIMAL_SUN_HOLIDAY" }, + }; + + return payRuleSet; + } + + /// + /// as it exists in customer databases created BEFORE the + /// tier correction. Same Name, but GRUNDLOVSDAG is the plain Sunday-style ladder + /// [7200 OVERTIME_50, null OVERTIME_80] — two tiers, tier 1 non-null, yet no + /// normal-time boundary anywhere in it. Reading 7200 s as the boundary would shrink + /// the ordinary-working-time half of Grundlovsdag to two hours, so this row must keep + /// its historical pure-tier treatment. + /// + internal static PayRuleSet AndetArbejdeLegacyTiers() + { + var payRuleSet = AndetArbejde(); + + payRuleSet.DayRules.Single(r => r.DayCode == "GRUNDLOVSDAG").Tiers = new List + { + new() { Order = 1, UpToSeconds = LegacyGrundlovsdagFirstStepSeconds, PayCode = "OVERTIME_50" }, + new() { Order = 2, UpToSeconds = null, PayCode = "OVERTIME_80" }, + }; + + return payRuleSet; + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs index 6a327ed4..32ccce51 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PraktikantPayLineRoutingTests.cs @@ -3,6 +3,7 @@ using System.Linq; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; using TimePlanning.Pn.Test.Helpers; @@ -813,6 +814,270 @@ public void Stald_Grundlovsdag_PauseAcrossNoon_NoonSplitUsesWorkedSecondsOnly() ("ANIMAL_SUN_HOLIDAY", 9000)); } + // ------------------------------------------------------------------ + // 11. STALE PRESET SNAPSHOTS — pre-correction rows in customer databases. + // + // Preset definitions are COPIED INTO the customer's database at create time, so a + // customer who created a praktikant rule set before the tiers were corrected still + // holds the OLD rows — under the very same, unchanged Name. The name gate therefore + // still fires on them, and without a shape guard the engine would reinterpret tiers + // that do not encode a normal-time boundary at all. + // + // The concrete harm on the old Staldarbejde SATURDAY rule + // ([21600 SAT_NORMAL, null SAT_ANIMAL_AFTERNOON]): tier 1's 21600 s is a MIRROR of the + // 12:00 clock band, not a boundary, and tier 2 is a fixed kr/dag AFTERNOON supplement. + // Reading it as a boundary truncates the bands at 21600 s and pays the overflow as + // SAT_ANIMAL_AFTERNOON — an afternoon supplement to a worker who went home at noon. + // + // PayRuleSetLock.HasNormalTimeBoundaryShape requires the tiers to match the CORRECTED + // encoding (26640 / 33840 OVERTIME_50 / null OVERTIME_80), so these rows fall through + // to the historical path untouched. Principle: when the data is not what the new + // interpretation assumes, do not reinterpret it. + // + // These tests should be DELETED together with the fixtures once a data migration has + // rewritten the stale rows. + // ------------------------------------------------------------------ + + [Test] + public void Legacy_Stald_Saturday_8h_EntirelyBeforeNoon_KeepsBandsOnly_NoAfternoonSupplement() + { + // THE REPORTED HARM, LOCKED DOWN. 04:00 → 12:00 = 28800 s, entirely before noon. + // Historical (and correct) bands-only: the whole segment (14400, 43200) lies + // inside the 00:00–12:00 band → SAT_NORMAL 28800, nothing else. + // With the name-only gate it became: bandSeconds = min(28800, 21600) = 21600, + // truncating the segment to 04:00 → 10:00 (SAT_NORMAL 21600), and the remaining + // 7200 s fell to tier 2 → SAT_ANIMAL_AFTERNOON 7200 — an afternoon supplement + // for work that ended at noon. + var lines = Run(Saturday, PraktikantFixtures.StaldarbejdeLegacyTiers(), + SingleShift(Saturday, 4 * Hour, 8 * Hour), 8 * Hour); + + AssertPayLines(lines, 28800, ("SAT_NORMAL", 28800)); + Assert.That(lines.Any(l => l.PayCode == "SAT_ANIMAL_AFTERNOON"), Is.False, + "A shift ending at noon must never earn the afternoon supplement"); + Assert.That(lines.Any(l => l.PayCode!.StartsWith("OVERTIME")), Is.False, + "Stale mirror tiers must not be reinterpreted as an overtime progression"); + } + + [Test] + public void Legacy_Stald_Saturday_MidnightToNoon_12h_AllSatNormal() + { + // 00:00 → 12:00 = 43200 s, the full morning band. Bands-only attributes every + // second to the 00:00–12:00 band → SAT_NORMAL 43200. + // With the name-only gate this flipped a full SIX HOURS: SAT_NORMAL 21600 + + // SAT_ANIMAL_AFTERNOON 21600 (43200 - 21600 overflowing onto tier 2). + var lines = Run(Saturday, PraktikantFixtures.StaldarbejdeLegacyTiers(), + SingleShift(Saturday, 0, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, ("SAT_NORMAL", 43200)); + Assert.That(lines.Any(l => l.PayCode == "SAT_ANIMAL_AFTERNOON"), Is.False, + "No second of a midnight-to-noon shift is in the afternoon band"); + } + + [Test] + public void Legacy_Stald_Saturday_06to18_12h_BandsAcrossTheWholeDay() + { + // 06:00 → 18:00 = 43200 s. Bands-only means NO truncation at any boundary; every + // worked second is attributed by clock position across the whole 12 h: + // SAT_NORMAL 06:00 → 12:00 = 21600 s + // SAT_ANIMAL_AFTERNOON 12:00 → 18:00 = 21600 s + // Here the afternoon supplement IS earned — the guard restores the historical + // reading, it does not suppress the supplement. + var lines = Run(Saturday, PraktikantFixtures.StaldarbejdeLegacyTiers(), + 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, + "Stale mirror tiers must not produce overtime lines"); + } + + [Test] + public void Legacy_Andet_Grundlovsdag_8h_KeepsHistoricalSundayLadder_NoNoonSplit() + { + // Stale GRUNDLOVSDAG tiers [7200 OVERTIME_50, null OVERTIME_80]: two tiers with a + // non-null tier 1, so the old Count > 1 test passed — but 7200 s is the Sunday + // ladder's first step, not a normal-time boundary. HasNormalTimeBoundaryShape now + // rejects it, CalculateGrundlovsdagPayLines returns null, and TryGetDayType returns + // false for GRUNDLOVSDAG, so the pure tier path runs over the full 28800 s: + // tier 1 UpTo 7200 → OVERTIME_50 = 7200 s + // tier 2 UpTo null → OVERTIME_80 = 28800 - 7200 = 21600 s + // No NORMAL line and no noon boundary anywhere — exactly the historical result. + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejdeLegacyTiers(), + SingleShift(Grundlovsdag, 6 * Hour, 8 * Hour), 8 * Hour); + + AssertPayLines(lines, 28800, + ("OVERTIME_50", 7200), + ("OVERTIME_80", 21600)); + Assert.That(lines.Any(l => l.PayCode == "NORMAL"), Is.False, + "The noon split must not be applied to a stale Grundlovsdag rule"); + } + + [Test] + public void Legacy_Stald_Sunday_12h_SingleTier_StaysAllAnimalSunHoliday() + { + // The stale SUNDAY rule has a SINGLE tier, so it already failed the Count > 1 test + // and never entered the split. This guards that it stays that way: 06:00 → 18:00 = + // 43200 s, all inside the single 00:00–24:00 ANIMAL_SUN_HOLIDAY band, with no + // truncation at 26640 and therefore no overtime lines. + var lines = Run(Sunday, PraktikantFixtures.StaldarbejdeLegacyTiers(), + SingleShift(Sunday, 6 * Hour, 12 * Hour), 12 * Hour); + + AssertPayLines(lines, 43200, ("ANIMAL_SUN_HOLIDAY", 43200)); + Assert.That(lines.Any(l => l.PayCode!.StartsWith("OVERTIME")), Is.False, + "A single-tier stale rule must not produce overtime lines"); + } + + [Test] + public void PositiveControl_CorrectedStaldSaturday_StillTakesSplitPath() + { + // THE POSITIVE CONTROL. The guard must exclude ONLY stale data. The corrected + // fixture — same date, same shift as Legacy_Stald_Saturday_06to18_12h above — + // still takes the new split path and produces exactly the expectations of + // Stald_Saturday_12h_BandsUntilNorm_ThenOvertimeTiers: + // bands over the first 26640 s (06:00 → 13:24): SAT_NORMAL 21600 + + // SAT_ANIMAL_AFTERNOON 5040; overflow 16560 → OVERTIME_50 7200, OVERTIME_80 9360. + // Contrast with the legacy test above, where the SAME shift yields 21600/21600 and + // no overtime — that difference is the whole point of the shape check. + 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 PositiveControl_CorrectedAndetGrundlovsdag_StillTakesNoonSplit() + { + // Same shift as Legacy_Andet_Grundlovsdag_8h above (06:00 → 14:00 = 28800 s), on + // the CORRECTED fixture, to show the guard did not disable the noon split: + // Step 1: normal time = min(28800, 26640) = 26640 → 06:00 → 13:24; + // overtime = 28800 - 26640 = 2160. + // 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 tiers 2..3 from 26640: OVERTIME_50 2160 (cap 33840). + // MergeByPayCode sums the two disjoint OVERTIME_50 sets: 5040 + 2160 = 7200. + // 21600 + 7200 = 28800. + var lines = Run(Grundlovsdag, PraktikantFixtures.AndetArbejde(), + SingleShift(Grundlovsdag, 6 * Hour, 8 * Hour), 8 * Hour); + + AssertPayLines(lines, 28800, + ("NORMAL", 21600), + ("OVERTIME_50", 7200)); + } + + // ---- The shape predicate itself, unit-tested ---- + + [Test] + public void ShapeGuard_AcceptsEveryCorrectedPraktikantDayRule() + { + // Every day rule of both corrected presets that can reach either gated site must + // pass, or the guard would silently disable the split for legitimate current data. + foreach (var payRuleSet in new[] + { + PraktikantFixtures.Staldarbejde(), + PraktikantFixtures.AndetArbejde() + }) + { + foreach (var dayCode in new[] { "WEEKDAY", "SATURDAY", "GRUNDLOVSDAG" }) + { + var tiers = payRuleSet.DayRules.Single(r => r.DayCode == dayCode) + .Tiers.OrderBy(t => t.Order).ToList(); + + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(tiers), Is.True, + $"{payRuleSet.Name} / {dayCode} is corrected data and must be accepted"); + } + } + + // Staldarbejde's SUNDAY and HOLIDAY rules also carry the corrected boundary + // (they are banded days, so they reach the split site). + var stald = PraktikantFixtures.Staldarbejde(); + foreach (var dayCode in new[] { "SUNDAY", "HOLIDAY" }) + { + var tiers = stald.DayRules.Single(r => r.DayCode == dayCode) + .Tiers.OrderBy(t => t.Order).ToList(); + + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(tiers), Is.True, + $"Staldarbejde / {dayCode} is corrected data and must be accepted"); + } + } + + [Test] + public void ShapeGuard_RejectsEveryStalePraktikantDayRule() + { + var legacyStald = PraktikantFixtures.StaldarbejdeLegacyTiers(); + foreach (var dayCode in new[] { "SATURDAY", "SUNDAY", "HOLIDAY", "GRUNDLOVSDAG" }) + { + var tiers = legacyStald.DayRules.Single(r => r.DayCode == dayCode) + .Tiers.OrderBy(t => t.Order).ToList(); + + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(tiers), Is.False, + $"Stale Staldarbejde / {dayCode} must be rejected"); + } + + var legacyAndet = PraktikantFixtures.AndetArbejdeLegacyTiers(); + var grundlovsdagTiers = legacyAndet.DayRules.Single(r => r.DayCode == "GRUNDLOVSDAG") + .Tiers.OrderBy(t => t.Order).ToList(); + + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(grundlovsdagTiers), Is.False, + "Stale Andet arbejde / GRUNDLOVSDAG must be rejected"); + } + + [Test] + public void ShapeGuard_RejectsNullAndWrongArity() + { + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(null), Is.False); + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(new List()), Is.False); + + // Correct boundary and codes, but a fourth tier appended — not the shipped shape, + // so we do not claim to know how to read it. + var fourTiers = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = 43200, PayCode = "OVERTIME_80" }, + new() { Order = 4, UpToSeconds = null, PayCode = "OVERTIME_100" }, + }; + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(fourTiers), Is.False); + } + + [Test] + public void ShapeGuard_RejectsRightArityButWrongBoundaryOrCodes() + { + // Three tiers and the right pay codes, but the boundary is a different number — + // a hand-edited or differently-versioned row we must not reinterpret. + var wrongBoundary = new List + { + new() { Order = 1, UpToSeconds = 21600, PayCode = "SAT_NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = null, PayCode = "OVERTIME_80" }, + }; + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(wrongBoundary), Is.False); + + // Right boundaries, but tier 2/3 are supplement codes rather than overtime steps — + // i.e. a mirror encoding, exactly what must not be reinterpreted. + var wrongCodes = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "SAT_NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "SAT_ANIMAL_AFTERNOON" }, + new() { Order = 3, UpToSeconds = null, PayCode = "ANIMAL_SUN_HOLIDAY" }, + }; + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(wrongCodes), Is.False); + + // Right codes and boundary, but tier 3 is closed rather than open-ended. + var closedLastTier = new List + { + new() { Order = 1, UpToSeconds = 26640, PayCode = "NORMAL" }, + new() { Order = 2, UpToSeconds = 33840, PayCode = "OVERTIME_50" }, + new() { Order = 3, UpToSeconds = 86400, PayCode = "OVERTIME_80" }, + }; + Assert.That(PayRuleSetLock.HasNormalTimeBoundaryShape(closedLastTier), Is.False); + } + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ 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 f639ae2c..c2fedea8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs @@ -7,6 +7,7 @@ The MIT License (MIT) using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; namespace TimePlanning.Pn.Infrastructure.Helpers; @@ -160,4 +161,79 @@ internal static bool IsNormalTimeSplitPresetName(string name) var normalized = NormalizePresetName(name); return normalized.Length > 0 && NormalizedNormalTimeSplitPresetNames.Contains(normalized); } + + // ------------------------------------------------------------------ + // Normal-time / overtime split — SHAPE GUARD FOR STALE PRESET SNAPSHOTS + // ------------------------------------------------------------------ + + /// + /// Number of tiers in a corrected praktikant day rule: the normal-time boundary + /// plus the two overtime steps. + /// + private const int NormalTimeBoundaryTierCount = 3; + + /// + /// Tier 1's cutoff in the corrected presets: the daily normal-time boundary, + /// 7 h 24 m (37 h ÷ 5). This is the value the split reads as "normal time ends here". + /// + internal const int NormalTimeBoundarySeconds = 26640; + + /// + /// Tier 2's cumulative cutoff in the corrected presets: the top of the 50 % overtime + /// step, 9 h 24 m (the boundary plus 2 h). + /// + internal const int Overtime50BoundarySeconds = 33840; + + /// Tier 2's pay code in the corrected presets — the 50 % overtime step. + private const string Overtime50PayCode = "OVERTIME_50"; + + /// Tier 3's pay code in the corrected presets — the open-ended 80 % overtime step. + private const string Overtime80PayCode = "OVERTIME_80"; + + /// + /// True when a day rule's ordered tiers actually ENCODE a normal-time boundary + /// followed by the two overtime steps, i.e. they match the corrected praktikant + /// preset shape exactly: + /// + /// tier 1: UpToSeconds == 26640 (the normal-time boundary; its pay code varies + /// legitimately per day — SAT_NORMAL, ANIMAL_SUN_HOLIDAY, NORMAL — so it + /// is deliberately NOT constrained here) + /// tier 2: UpToSeconds == 33840, PayCode == "OVERTIME_50" + /// tier 3: UpToSeconds == null, PayCode == "OVERTIME_80" + /// + /// WHY THIS EXISTS — NAME MATCHING ALONE IS NOT ENOUGH + /// --------------------------------------------------- + /// answers "which AGREEMENT is this rule + /// set", and that is a stable question. But preset definitions are COPY-AT-CREATE-TIME + /// SNAPSHOTS: a customer who created the praktikant rule set before the tiers were + /// corrected still holds the OLD rows in their database, under the very same name. + /// Those pre-correction rows encode something completely different — e.g. the old + /// Staldarbejde SATURDAY rule was [21600 SAT_NORMAL, null SAT_ANIMAL_AFTERNOON], a + /// MIRROR of the clock bands, not a normal-time boundary. Reinterpreting it as a + /// boundary attributes 21600 s to the bands and dumps the overflow on tier 2's + /// SAT_ANIMAL_AFTERNOON — a fixed kr/dag afternoon supplement — even for a shift that + /// ended at noon. + /// + /// So the name identifies the agreement, and this predicate confirms the DATA really + /// speaks the new dialect. When it does not, the caller must leave the row on the + /// historical path rather than reinterpret it: when the data is not what the new + /// interpretation assumes, do not reinterpret it. + /// + /// This is a guard, not the cure. The durable fix is a DATA MIGRATION that rewrites + /// the stale praktikant day rules to the corrected tiers; once every customer row has + /// been migrated this predicate becomes a no-op and can be retired. + /// + internal static bool HasNormalTimeBoundaryShape(IReadOnlyList? orderedTiers) + { + if (orderedTiers is not { Count: NormalTimeBoundaryTierCount }) + { + return false; + } + + return orderedTiers[0].UpToSeconds == NormalTimeBoundarySeconds + && orderedTiers[1].UpToSeconds == Overtime50BoundarySeconds + && orderedTiers[1].PayCode == Overtime50PayCode + && orderedTiers[2].UpToSeconds == null + && orderedTiers[2].PayCode == Overtime80PayCode; + } } 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 1ab1c7f3..1bd19424 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -4750,8 +4750,22 @@ internal static List CalculatePayLinesForDay( // 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. + // + // AND THE NAME IS NOT ENOUGH ON ITS OWN. Preset definitions are + // copy-at-create-time snapshots, so a customer who created this rule set + // BEFORE the tiers were corrected still holds the OLD rows under the very + // same name — e.g. the old Staldarbejde SATURDAY rule was + // [21600 SAT_NORMAL, null SAT_ANIMAL_AFTERNOON], a MIRROR of the clock + // bands rather than a normal-time boundary. Reading that as a boundary + // truncates the bands at 21600 s and pays the overflow as + // SAT_ANIMAL_AFTERNOON — a fixed kr/dag afternoon supplement — to a worker + // who went home at noon. HasNormalTimeBoundaryShape therefore requires the + // tiers to actually encode the corrected boundary + overtime progression; + // stale rows fall through to the historical path untouched. A data + // migration of those rows is the durable fix — see PayRuleSetLock. if (usesNormalTimeSplit && orderedTiers is { Count: > 1 } + && PayRuleSetLock.HasNormalTimeBoundaryShape(orderedTiers) && orderedTiers[0].UpToSeconds is { } normalSeconds) { var bandSeconds = Math.Min(totalSeconds, normalSeconds); @@ -5002,7 +5016,18 @@ internal static List CalculatePayLinesForDay( .OrderBy(t => t.Order) .ToList(); + // The name identifies the AGREEMENT, but the DB row may PREDATE the tier + // correction: preset definitions are copy-at-create-time snapshots, so a customer + // who created this rule set earlier still holds the old GRUNDLOVSDAG tiers under + // the same name — Andet arbejde's were [7200 OVERTIME_50, null OVERTIME_80], a + // plain Sunday-style ladder with no normal-time boundary in it at all. Reading + // 7200 s as "normal time ends here" would move the noon split and shrink the + // ordinary-working-time half of the day to two hours. HasNormalTimeBoundaryShape + // confirms the tiers really encode the corrected boundary; when they do not we + // return null and the caller keeps the historical routing. A data migration of the + // stale rows is the durable fix — see PayRuleSetLock. if (orderedTiers is not { Count: > 1 } + || !PayRuleSetLock.HasNormalTimeBoundaryShape(orderedTiers) || orderedTiers[0].UpToSeconds is not { } normalSeconds) { return null;