mAi: #103 - t-paliad-271 Wave 2 Tier-3 Slice A — deadline-rule primitives

Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.

Primitive 1 — duration_unit='working_days':
  Calculator walks day-by-day skipping weekends + court holidays via
  HolidayService.IsNonWorkingDay. Event day is not counted; result is
  always a working day for the (country, regime). Unlocks T1.8/T1.9
  modeling and the R.198 / R.213 alt leg.

Primitive 2 — combine_op='max' (and 'min'):
  When alt_duration_value + alt_duration_unit + combine_op are set, the
  calculator evaluates both legs and picks the later (max) or earlier
  (min) of the two adjusted end dates. The DB already had two rules
  shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
  the calculator was silently dropping the alt leg.

Primitive 5 — timing='before' backward snap-to-working-day:
  For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
  2 weeks before) the calculator now snaps to the PRECEDING working day
  when the computed cut-off lands on a weekend/holiday. Forward snap
  (the prior behavior) would push the cut-off past the statutory limit
  and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
  Backward as the symmetric counterpart of AdjustForNonWorkingDays.

Migration 128 — DB schema:
  Adds CHECK constraints on deadline_rules.duration_unit and
  alt_duration_unit pinning the allowed set to days/weeks/months/
  working_days. Live data audited and passes (no rows excluded).

Tests (12 new + 1 flipped):
  - 5 working_days cases: forward over weekend, 20wd anchored on Fri,
    across Karfreitag/Ostermontag, across year boundary, backward
    from Friday, anchored on Saturday.
  - 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
    Karfreitag → Thu.
  - 4 combine_op cases: max with primary winning, max with alt winning
    over Christmas+Neujahr cluster, min with primary winning, NULL-alt
    short-circuit.
  - TestCalculateEndDate_BeforeTiming renamed and flipped from forward
    (Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).

No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.

Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
This commit is contained in:
mAi
2026-05-25 16:06:35 +02:00
parent ff503ffc43
commit d3c28009de
5 changed files with 489 additions and 20 deletions

View File

@@ -0,0 +1,11 @@
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
-- No data revert necessary — the constraint addition was purely
-- additive and validated against live data before adding.
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;

View File

@@ -0,0 +1,36 @@
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
-- 'working_days' added to the allowed set.
--
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
-- combine-max pattern). The schema currently accepts free-text on
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
-- in the DB but were silently dropped by the calculator. Adding the CHECK
-- pins the contract and prevents typos.
--
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
-- path stays in lockstep with the primary path.
--
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
-- alt-unit rows already at 'working_days' — both shapes pass.
--
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_duration_unit_check
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
CHECK (alt_duration_unit IS NULL
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));

View File

@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
}
// CalculateEndDate applies a single rule's duration + timing to the event date,
// then bumps forward off non-working days for the given (country, regime).
// Returns (adjusted, original, didAdjust).
// then bumps off non-working days for the given (country, regime). For
// rules with both a primary and an alt duration (alt_duration_value/_unit)
// and a combine_op of 'max' or 'min', both legs are computed independently
// and combined per the operator — this implements RoP R.198 / R.213
// ("31 days OR 20 working days, whichever is longer") and the equivalent
// shape under EPC. Returns (adjusted, original, didAdjust).
//
// Snap direction follows timing: 'after' snaps forward to the next
// working day (RoP R.300.b — period extends to the next working day),
// 'before' snaps *backward* to the preceding working day so the
// statutory cut-off is not pushed past its hard limit.
//
// duration_unit='working_days' walks day-by-day via the holiday service
// (skipping weekends + court holidays), so its result is always already a
// working day — no post-arithmetic snap needed for that leg.
//
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
// combine_op + alt_duration_*: compute the alt leg independently,
// then pick the later (max) or earlier (min) of the two adjusted
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
// days vs. 20 working days, whichever is longer).
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
switch *rule.CombineOp {
case "max":
if altAdj.After(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
case "min":
if altAdj.Before(adjusted) {
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
}
}
}
return adjusted, raw, wasAdjusted
}
// computeLeg evaluates a single (value, unit) duration against the event
// date in the given timing direction and snap-adjusts the result. Returns
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
// occurred. working_days arithmetic never needs a snap (the walker lands
// on a working day by construction).
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
sign := 1
if timing == "before" {
sign = -1
}
switch rule.DurationUnit {
case "days":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
case "months":
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
if unit == "working_days" {
return raw, raw, false
}
if timing == "before" {
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
}
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
}
original := endDate
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
return adjusted, original, wasAdjusted
// addDuration adds `sign * value` of the given unit to eventDate. For
// 'working_days' it walks day-by-day skipping weekends and court
// holidays via the holiday service.
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
switch unit {
case "days":
return eventDate.AddDate(0, 0, sign*value)
case "weeks":
return eventDate.AddDate(0, 0, sign*value*7)
case "months":
return eventDate.AddDate(0, sign*value, 0)
case "working_days":
return c.addWorkingDays(eventDate, sign*value, country, regime)
}
return eventDate
}
// addWorkingDays walks `n` business days from `date` (negative `n` walks
// backward). The event day itself is never counted; we step first, then
// skip past non-working days, repeated n times. Result is always a
// working day for the given (country, regime). Matches UPC RoP R.300.b's
// "the day on which the event happens shall not be counted" convention
// applied to the business-day axis.
//
// Bound: each business-day step is bounded by a 60-day inner cap so a
// misconfigured holiday table can never spin forever. The longest
// real-world non-working run between adjacent business days is the
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
if n == 0 {
return date
}
step := 1
count := n
if n < 0 {
step = -1
count = -n
}
cur := date
for i := 0; i < count; i++ {
cur = cur.AddDate(0, 0, step)
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}
// CalculateFromRules calculates deadlines for a slice of rules using the

View File

@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
}
}
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
// "no later than X before the oral hearing"), a computed cut-off that
// lands on a weekend / holiday must snap *backward* to the preceding
// working day. Forward snap would push the cut-off past the statutory
// limit and miss the deadline. See
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
DurationUnit: "months",
Timing: ptr("before"),
}
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
// Adjust: Sunday → Monday 2026-03-16.
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
// in 2026, so no extra holiday in this window).
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
if !original.Equal(wantOrig) {
t.Errorf("original: got %s, want %s", original, wantOrig)
}
if !adjusted.Equal(wantAdj) {
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
}
}
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
// Sat → Karfreitag → Thu 2026-04-02.
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "1-month before, Ostern cluster",
DurationValue: 1,
DurationUnit: "months",
Timing: ptr("before"),
}
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
if !wasAdjusted {
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
}
}
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
// working_days arithmetic lands on a working day by construction, so the
// "snap" reports no adjustment and original == adjusted.
if !original.Equal(want) {
t.Errorf("original: got %s, want %s", original, want)
}
if wasAdjusted {
t.Error("working_days result should not report a snap adjustment")
}
}
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
// 20 working days lands on the Friday four weeks later. Anchor Fri
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
// window. This exercises the R.198 / R.213 "20 working days" leg.
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "20 working days",
DurationValue: 20,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
// (3). Result = Thu 2026-04-09.
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days over Ostern",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
// Tue 06 (5). Result = Tue 2026-01-06.
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days over year-end",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
// Fri 2026-04-10.
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "5 working days before",
DurationValue: 5,
DurationUnit: "working_days",
Timing: ptr("before"),
}
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "3 working days from Saturday",
DurationValue: 3,
DurationUnit: "working_days",
Timing: ptr("after"),
}
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
// working days, whichever is longer". Anchor Mon 2026-01-12.
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
//
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "31d OR 20wd, max",
DurationValue: 31,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
// where the working-days side overshoots. Anchor over a long-weekend
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
// + snap ≈ 20wd + cluster.
//
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
// "alt wins", we use a configurable anchor and check the relative order
// instead.
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
// arrives much later. Use 14 days vs 20 working_days to make alt
// reliably win on this stretch.
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, max",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "14d OR 20wd, min",
DurationValue: 14,
DurationUnit: "days",
Timing: ptr("after"),
AltDurationValue: ptr(20),
AltDurationUnit: ptr("working_days"),
CombineOp: ptr("min"),
}
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
}
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
// the primary-only result (defensive: drift in seed data shouldn't crash
// the calculator). Same as the basic days test but with combine_op set
// and alt fields nil.
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Primary only, stray combine_op",
DurationValue: 10,
DurationUnit: "days",
Timing: ptr("after"),
CombineOp: ptr("max"),
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
if !adjusted.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adjusted, want)
}
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
// 2026-08-29") locks the live behaviour.
}

View File

@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
return h != nil && h.IsClosure
}
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
// lands on a working day for the given (country, regime). Used for
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
// the oral hearing") — when the computed cut-off lands on a weekend or
// public holiday, the lawyer must finish *earlier*, not later. Forward
// snap would push the cut-off past the statutory limit and cause the
// step to be filed too late. Bound by the same 60-iter cap as the
// forward variant.
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDays moves the date forward to the next working day for
// the given (country, regime). Returns adjusted date, the original
// (unmodified) date, and whether any adjustment was made.