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).
176 lines
6.6 KiB
Go
176 lines
6.6 KiB
Go
package services
|
|
|
|
import (
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// CalculatedDeadline is one computed deadline (rule + due date + adjustment info).
|
|
type CalculatedDeadline struct {
|
|
RuleCode string `json:"rule_code"`
|
|
RuleID string `json:"rule_id"`
|
|
Title string `json:"title"`
|
|
DueDate string `json:"due_date"` // YYYY-MM-DD, after holiday/weekend adjust
|
|
OriginalDueDate string `json:"original_due_date"` // YYYY-MM-DD, before adjust
|
|
WasAdjusted bool `json:"was_adjusted"`
|
|
}
|
|
|
|
// DeadlineCalculator turns rules into dates given a trigger event.
|
|
type DeadlineCalculator struct {
|
|
holidays *HolidayService
|
|
}
|
|
|
|
// NewDeadlineCalculator wires the calculator to the holiday service.
|
|
func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
|
|
return &DeadlineCalculator{holidays: holidays}
|
|
}
|
|
|
|
// CalculateEndDate applies a single rule's duration + timing to the event date,
|
|
// 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) {
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
// given (country, regime) for non-working-day adjustment. Rules with
|
|
// duration == 0 (court-set hearings, decisions) return the event date itself.
|
|
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule, country, regime string) []CalculatedDeadline {
|
|
results := make([]CalculatedDeadline, 0, len(rules))
|
|
for _, r := range rules {
|
|
var adjusted, original time.Time
|
|
var wasAdjusted bool
|
|
|
|
if r.DurationValue > 0 {
|
|
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, r, country, regime)
|
|
} else {
|
|
adjusted, original = eventDate, eventDate
|
|
}
|
|
|
|
code := ""
|
|
if r.SubmissionCode != nil {
|
|
code = *r.SubmissionCode
|
|
}
|
|
|
|
results = append(results, CalculatedDeadline{
|
|
RuleCode: code,
|
|
RuleID: r.ID.String(),
|
|
Title: r.Name,
|
|
DueDate: adjusted.Format("2006-01-02"),
|
|
OriginalDueDate: original.Format("2006-01-02"),
|
|
WasAdjusted: wasAdjusted,
|
|
})
|
|
}
|
|
return results
|
|
}
|