Merge: t-paliad-271 — Tier 3 deadline-rule primitives Slice A (working_days + combine_op + before-mode, mig 128) (m/paliad#103)

This commit is contained in:
mAi
2026-05-25 16:08:33 +02:00
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.