From 958ad430179c96e4222f05ffb0882de48fa726ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 7 Aug 2026 16:19:24 +0200 Subject: [PATCH] fix(praktikant): only split days whose tiers encode a normal-time boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normal-time/overtime split was gated on the preset NAME alone. Preset definitions are copy-at-create-time snapshots, so a customer who created the praktikant rule set before the tier correction still holds the OLD tiers in their database, and no migration updates them. The name still matched, so the split fired on stale data with the wrong boundary. Concretely, Staldarbejde SATURDAY used to be [21600 SAT_NORMAL, null SAT_ANIMAL_AFTERNOON]. Two tiers with a non-null first one satisfied the old gate, so the split ran with normalSeconds = 21600 and emitted the overflow using the old tier 2 pay code — a fixed kr/dag animal-care supplement. A Saturday worked 04:00-12:00 went from SAT_NORMAL 28800 to SAT_NORMAL 21600 + SAT_ANIMAL_AFTERNOON 7200: an afternoon supplement for work that ended at noon. A 00:00-12:00 Saturday flipped six full hours. That is the exact harm the identity gate was written to prevent. It checked the name, but the data behind the name can predate the correction. The split now additionally requires the day rule's tiers to match the shape it assumes: 26640 / 33840 OVERTIME_50 / null OVERTIME_80. Stale rows fall through to their historical bands-only treatment, unchanged. Tier 1's pay code is deliberately not constrained — it legitimately varies (SAT_NORMAL, ANIMAL_SUN_HOLIDAY, NORMAL). The same guard is applied to the Grundlovsdag path. A data migration rewriting the old rows is the durable fix; until then this keeps existing customers on the behaviour they have been paid against. 11 tests added, built from the pre-correction tier values as they exist in customer databases. No existing test changed. Co-Authored-By: Claude Fable 5 --- .../Helpers/PraktikantFixtures.cs | 91 ++++++ .../PraktikantPayLineRoutingTests.cs | 265 ++++++++++++++++++ .../Infrastructure/Helpers/PayRuleSetLock.cs | 76 +++++ .../TimePlanningWorkingHoursService.cs | 25 ++ 4 files changed, 457 insertions(+) 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;