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).
414 lines
15 KiB
Go
414 lines
15 KiB
Go
// Package services holds the Paliad domain services backed by paliad.* tables.
|
||
package services
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/jmoiron/sqlx"
|
||
)
|
||
|
||
// Country and regime constants — keep in sync with the paliad.countries
|
||
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
|
||
const (
|
||
CountryDE = "DE"
|
||
RegimeUPC = "UPC"
|
||
RegimeEPO = "EPO"
|
||
)
|
||
|
||
// Holiday is a non-working day. Mirrors paliad.holidays + the German federal
|
||
// hardcoded set used as a fallback when the DB lookup misses.
|
||
//
|
||
// Country is the ISO-3166 alpha-2 country whose national calendar this entry
|
||
// belongs to (e.g. "DE", "FR"). Empty string when the entry is regime-only.
|
||
// Regime is the supranational layer ("UPC" / "EPO") for entries that apply
|
||
// across UPC LDs / EPO branches regardless of country (e.g. UPC summer
|
||
// vacation). Empty string when the entry is country-only. Every Holiday
|
||
// carries at least one of the two.
|
||
type Holiday struct {
|
||
Date time.Time
|
||
Name string
|
||
Country string
|
||
Regime string
|
||
IsVacation bool // part of court vacation period
|
||
IsClosure bool // single-day closure (public holiday)
|
||
}
|
||
|
||
// AppliesTo returns true if this holiday should be considered when computing
|
||
// deadlines for a court with the given country + regime.
|
||
//
|
||
// A row matches when its Country equals the court's country, OR when its
|
||
// Regime equals the court's regime. UPC LD München (DE, UPC) therefore picks
|
||
// up both DE national rows and UPC regime rows; LG München (DE, "") picks up
|
||
// only DE national rows.
|
||
func (h Holiday) AppliesTo(country, regime string) bool {
|
||
if h.Country != "" && h.Country == country {
|
||
return true
|
||
}
|
||
if h.Regime != "" && regime != "" && h.Regime == regime {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HolidayService loads and caches per-year holidays from paliad.holidays,
|
||
// merging with German federal holidays as a safety net.
|
||
//
|
||
// Concurrency: cache is guarded by a sync.Map of *sync.Once per year, which
|
||
// fixes audit §1.6 (the original KanzlAI version had an unprotected map).
|
||
// Each year is loaded at most once across all concurrent callers.
|
||
//
|
||
// The cache stores every row for a year regardless of country/regime. Lookup
|
||
// methods filter post-cache via Holiday.AppliesTo so multiple courts touching
|
||
// the same year share one DB hit.
|
||
type HolidayService struct {
|
||
db *sqlx.DB
|
||
cache sync.Map // year (int) → *yearEntry
|
||
}
|
||
|
||
type yearEntry struct {
|
||
once sync.Once
|
||
holidays []Holiday
|
||
err error
|
||
}
|
||
|
||
// NewHolidayService creates a new holiday service.
|
||
func NewHolidayService(db *sqlx.DB) *HolidayService {
|
||
return &HolidayService{db: db}
|
||
}
|
||
|
||
type dbHoliday struct {
|
||
ID int `db:"id"`
|
||
Date time.Time `db:"date"`
|
||
Name string `db:"name"`
|
||
Country *string `db:"country"`
|
||
Regime *string `db:"regime"`
|
||
State *string `db:"state"`
|
||
HolidayType string `db:"holiday_type"`
|
||
}
|
||
|
||
// LoadHolidaysForYear loads holidays for a year (cached, race-safe). Returns
|
||
// every row stored for that year — caller filters by country/regime.
|
||
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
|
||
v, _ := s.cache.LoadOrStore(year, &yearEntry{})
|
||
entry := v.(*yearEntry)
|
||
entry.once.Do(func() {
|
||
entry.holidays, entry.err = s.loadYear(year)
|
||
})
|
||
return entry.holidays, entry.err
|
||
}
|
||
|
||
func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
|
||
holidays := make([]Holiday, 0, 30)
|
||
|
||
if s.db != nil {
|
||
var rows []dbHoliday
|
||
err := s.db.SelectContext(context.Background(), &rows,
|
||
`SELECT id, date, name, country, regime, state, holiday_type
|
||
FROM paliad.holidays
|
||
WHERE EXTRACT(YEAR FROM date) = $1
|
||
ORDER BY date`, year)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load holidays for %d: %w", year, err)
|
||
}
|
||
for _, h := range rows {
|
||
country := ""
|
||
if h.Country != nil {
|
||
country = *h.Country
|
||
}
|
||
regime := ""
|
||
if h.Regime != nil {
|
||
regime = *h.Regime
|
||
}
|
||
holidays = append(holidays, Holiday{
|
||
Date: h.Date,
|
||
Name: h.Name,
|
||
Country: country,
|
||
Regime: regime,
|
||
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
|
||
IsVacation: h.HolidayType == "vacation",
|
||
})
|
||
}
|
||
}
|
||
|
||
// Merge German federal holidays so a misconfigured DB never silently
|
||
// returns a working day for, say, Christmas. Tagged country='DE' so the
|
||
// per-country filter applies them only to DE-jurisdictional callers.
|
||
seen := make(map[string]bool, len(holidays))
|
||
for _, h := range holidays {
|
||
if h.Country == CountryDE {
|
||
seen[h.Date.Format("2006-01-02")] = true
|
||
}
|
||
}
|
||
for _, h := range germanFederalHolidays(year) {
|
||
key := h.Date.Format("2006-01-02")
|
||
if !seen[key] {
|
||
holidays = append(holidays, h)
|
||
}
|
||
}
|
||
return holidays, nil
|
||
}
|
||
|
||
// IsHoliday returns the matching Holiday entry if the date is a holiday for
|
||
// the given (country, regime), else nil. country must not be empty; regime
|
||
// may be empty for non-UPC, non-EPO contexts.
|
||
func (s *HolidayService) IsHoliday(date time.Time, country, regime string) *Holiday {
|
||
holidays, err := s.LoadHolidaysForYear(date.Year())
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
key := date.Format("2006-01-02")
|
||
for i := range holidays {
|
||
if holidays[i].Date.Format("2006-01-02") != key {
|
||
continue
|
||
}
|
||
if !holidays[i].AppliesTo(country, regime) {
|
||
continue
|
||
}
|
||
return &holidays[i]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// IsNonWorkingDay returns true on weekends or closure-type holidays
|
||
// applicable to the given (country, regime).
|
||
//
|
||
// "Vacation" entries (today: UPC summer + winter judicial vacations per UPC
|
||
// AC decision 2023-05-26) are deliberately excluded — the Court continues to
|
||
// operate during them and they do not extend procedural deadlines (RoP /
|
||
// AC decision-on-judicial-vacation). They stay in paliad.holidays as
|
||
// informational metadata so callers of IsHoliday can still surface "this
|
||
// date overlaps with UPC vacation" if they want to. See t-paliad-121.
|
||
func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string) bool {
|
||
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||
return true
|
||
}
|
||
h := s.IsHoliday(date, country, regime)
|
||
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.
|
||
//
|
||
// Since t-paliad-121 vacations are no longer non-working, so the longest
|
||
// real-world run is Karfreitag → Ostermontag (~4 days) or Christmas-eve
|
||
// weekend → Neujahr (~6 days). The 60-iter cap is over-provisioned but
|
||
// kept as-is — it predates t-paliad-121 (the t-paliad-086 PR-3 history
|
||
// note explains the original 30 → 60 bump for full-vacation walks), and
|
||
// over-provisioning is harmless here.
|
||
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted time.Time, original time.Time, wasAdjusted bool) {
|
||
adjusted, original, wasAdjusted, _ = s.AdjustForNonWorkingDaysWithReason(date, country, regime)
|
||
return adjusted, original, wasAdjusted
|
||
}
|
||
|
||
// AdjustmentReason explains why a deadline was shifted off the originally
|
||
// computed date. The Fristenrechner UI uses it to render specific labels
|
||
// — "UPC-Sommerferien (27.7.–28.8.)" instead of the generic "Wochenende/
|
||
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||
// math bug. See t-paliad-119.
|
||
//
|
||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
||
type AdjustmentReason struct {
|
||
// Kind is the dominant cause; longest cause wins when several apply
|
||
// (vacation > public_holiday > weekend).
|
||
Kind string `json:"kind"`
|
||
// Holidays collects every named holiday encountered while walking past
|
||
// the non-working run, deduped by (date, name). May be empty when the
|
||
// only cause is a weekend.
|
||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||
// VacationName, VacationStart and VacationEnd describe the contiguous
|
||
// vacation block the original date sits in. Populated only when Kind
|
||
// == "vacation". Span boundaries are the first/last vacation day in
|
||
// the block (excludes the weekends that pad it).
|
||
VacationName string `json:"vacationName,omitempty"`
|
||
VacationStart string `json:"vacationStart,omitempty"`
|
||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||
// OriginalWeekday is the English weekday name of the original date —
|
||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||
// can localise it.
|
||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||
}
|
||
|
||
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
|
||
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
|
||
type HolidayDTO struct {
|
||
Date string `json:"date"`
|
||
Name string `json:"name"`
|
||
IsVacation bool `json:"isVacation,omitempty"`
|
||
IsClosure bool `json:"isClosure,omitempty"`
|
||
}
|
||
|
||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||
// explanation. Reason is nil when wasAdjusted is false.
|
||
//
|
||
// The walk semantics (forward, day-by-day, 60-iter bound) are identical to
|
||
// AdjustForNonWorkingDays — only an additional bookkeeping pass collects the
|
||
// holidays hit and decides the dominant Kind. Do not shrink the 60-iter
|
||
// bound; see AdjustForNonWorkingDays for the t-paliad-086 PR-2 history.
|
||
func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted time.Time, original time.Time, wasAdjusted bool, reason *AdjustmentReason) {
|
||
original = date
|
||
adjusted = date
|
||
|
||
var holidaysHit []HolidayDTO
|
||
seen := map[string]bool{}
|
||
var sawWeekend, sawVacation, sawPublicHoliday bool
|
||
var vacationName string
|
||
|
||
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||
sawWeekend = true
|
||
}
|
||
if h := s.IsHoliday(adjusted, country, regime); h != nil {
|
||
if h.IsVacation {
|
||
sawVacation = true
|
||
if vacationName == "" {
|
||
vacationName = h.Name
|
||
}
|
||
} else {
|
||
sawPublicHoliday = true
|
||
}
|
||
key := h.Date.Format("2006-01-02") + "|" + h.Name
|
||
if !seen[key] {
|
||
holidaysHit = append(holidaysHit, HolidayDTO{
|
||
Date: h.Date.Format("2006-01-02"),
|
||
Name: h.Name,
|
||
IsVacation: h.IsVacation,
|
||
IsClosure: h.IsClosure,
|
||
})
|
||
seen[key] = true
|
||
}
|
||
}
|
||
adjusted = adjusted.AddDate(0, 0, 1)
|
||
wasAdjusted = true
|
||
}
|
||
|
||
if !wasAdjusted {
|
||
return adjusted, original, false, nil
|
||
}
|
||
|
||
r := &AdjustmentReason{Holidays: holidaysHit}
|
||
switch {
|
||
case sawVacation:
|
||
r.Kind = "vacation"
|
||
r.VacationName = vacationName
|
||
if vs, ve, ok := s.findVacationBlock(original, country, regime); ok {
|
||
r.VacationStart = vs.Format("2006-01-02")
|
||
r.VacationEnd = ve.Format("2006-01-02")
|
||
}
|
||
case sawPublicHoliday:
|
||
r.Kind = "public_holiday"
|
||
default:
|
||
r.Kind = "weekend"
|
||
}
|
||
if sawWeekend && r.Kind == "weekend" {
|
||
r.OriginalWeekday = original.Weekday().String()
|
||
}
|
||
return adjusted, original, true, r
|
||
}
|
||
|
||
// findVacationBlock locates the contiguous vacation block that `date` lies
|
||
// in by scanning outward through non-working days for the given (country,
|
||
// regime). Returns the earliest and latest IsVacation entries reached
|
||
// before hitting a working day. Weekends inside the run are traversed
|
||
// (non-working) but don't extend the reported span — start/end are always
|
||
// real vacation entries.
|
||
func (s *HolidayService) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
|
||
var firstVac, lastVac time.Time
|
||
|
||
cur := date
|
||
for i := 0; i < 60; i++ {
|
||
if !s.IsNonWorkingDay(cur, country, regime) {
|
||
break
|
||
}
|
||
if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
|
||
firstVac = cur
|
||
}
|
||
cur = cur.AddDate(0, 0, -1)
|
||
}
|
||
|
||
cur = date.AddDate(0, 0, 1)
|
||
for i := 0; i < 60; i++ {
|
||
if !s.IsNonWorkingDay(cur, country, regime) {
|
||
break
|
||
}
|
||
if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
|
||
lastVac = cur
|
||
}
|
||
cur = cur.AddDate(0, 0, 1)
|
||
}
|
||
|
||
if firstVac.IsZero() && lastVac.IsZero() {
|
||
return time.Time{}, time.Time{}, false
|
||
}
|
||
if firstVac.IsZero() {
|
||
firstVac = lastVac
|
||
}
|
||
if lastVac.IsZero() {
|
||
lastVac = firstVac
|
||
}
|
||
return firstVac, lastVac, true
|
||
}
|
||
|
||
// germanFederalHolidays returns the 11 holidays observed in all 16 German Länder.
|
||
func germanFederalHolidays(year int) []Holiday {
|
||
em, ed := CalculateEasterSunday(year)
|
||
easter := time.Date(year, time.Month(em), ed, 0, 0, 0, 0, time.UTC)
|
||
return []Holiday{
|
||
{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", Country: CountryDE, IsClosure: true},
|
||
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", Country: CountryDE, IsClosure: true},
|
||
{Date: easter, Name: "Ostersonntag", Country: CountryDE, IsClosure: true},
|
||
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", Country: CountryDE, IsClosure: true},
|
||
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", Country: CountryDE, IsClosure: true},
|
||
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", Country: CountryDE, IsClosure: true},
|
||
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", Country: CountryDE, IsClosure: true},
|
||
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", Country: CountryDE, IsClosure: true},
|
||
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", Country: CountryDE, IsClosure: true},
|
||
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", Country: CountryDE, IsClosure: true},
|
||
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", Country: CountryDE, IsClosure: true},
|
||
}
|
||
}
|
||
|
||
// CalculateEasterSunday computes Easter Sunday for the given year using the
|
||
// Anonymous Gregorian algorithm. Returns (month 1-12, day 1-31).
|
||
func CalculateEasterSunday(year int) (int, int) {
|
||
a := year % 19
|
||
b := year / 100
|
||
c := year % 100
|
||
d := b / 4
|
||
e := b % 4
|
||
f := (b + 8) / 25
|
||
g := (b - f + 1) / 3
|
||
h := (19*a + b - d - g + 15) % 30
|
||
i := c / 4
|
||
k := c % 4
|
||
l := (32 + 2*e + 2*i - h - k) % 7
|
||
m := (a + 11*h + 22*l) / 451
|
||
month := (h + l - 7*m + 114) / 31
|
||
day := ((h + l - 7*m + 114) % 31) + 1
|
||
return month, day
|
||
}
|