feat(t-paliad-122): country+regime aware HolidayService + CourtService

Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.

Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.

New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.

Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
This commit is contained in:
m
2026-05-06 12:47:12 +02:00
parent a9d3695719
commit d72990ad1b
14 changed files with 498 additions and 114 deletions

View File

@@ -10,21 +10,59 @@ import (
"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
@@ -45,12 +83,14 @@ type dbHoliday struct {
ID int `db:"id"`
Date time.Time `db:"date"`
Name string `db:"name"`
Country string `db:"country"`
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).
// 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)
@@ -66,7 +106,7 @@ func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
if s.db != nil {
var rows []dbHoliday
err := s.db.SelectContext(context.Background(), &rows,
`SELECT id, date, name, country, state, holiday_type
`SELECT id, date, name, country, regime, state, holiday_type
FROM paliad.holidays
WHERE EXTRACT(YEAR FROM date) = $1
ORDER BY date`, year)
@@ -74,20 +114,33 @@ func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
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 with German federal holidays so a misconfigured DB never silently
// returns a working day for, say, Christmas.
// 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 {
seen[h.Date.Format("2006-01-02")] = true
if h.Country == CountryDE {
seen[h.Date.Format("2006-01-02")] = true
}
}
for _, h := range germanFederalHolidays(year) {
key := h.Date.Format("2006-01-02")
@@ -98,22 +151,29 @@ func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
return holidays, nil
}
// IsHoliday returns the matching Holiday entry if the date is a holiday, else nil.
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
// 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 {
return &holidays[i]
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.
// 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
@@ -121,17 +181,17 @@ func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
// 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) bool {
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)
h := s.IsHoliday(date, country, regime)
return h != nil && h.IsClosure
}
// AdjustForNonWorkingDays moves the date forward to the next working day.
// Returns adjusted date, the original (unmodified) date, and whether any
// adjustment was made.
// 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
@@ -139,8 +199,8 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
// 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) (adjusted time.Time, original time.Time, wasAdjusted bool) {
adjusted, original, wasAdjusted, _ = s.AdjustForNonWorkingDaysWithReason(date)
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
}
@@ -190,7 +250,7 @@ type HolidayDTO struct {
// 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) (adjusted time.Time, original time.Time, wasAdjusted bool, reason *AdjustmentReason) {
func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted time.Time, original time.Time, wasAdjusted bool, reason *AdjustmentReason) {
original = date
adjusted = date
@@ -199,11 +259,11 @@ func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time) (adju
var sawWeekend, sawVacation, sawPublicHoliday bool
var vacationName string
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted); i++ {
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); h != nil {
if h := s.IsHoliday(adjusted, country, regime); h != nil {
if h.IsVacation {
sawVacation = true
if vacationName == "" {
@@ -236,7 +296,7 @@ func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time) (adju
case sawVacation:
r.Kind = "vacation"
r.VacationName = vacationName
if vs, ve, ok := s.findVacationBlock(original); ok {
if vs, ve, ok := s.findVacationBlock(original, country, regime); ok {
r.VacationStart = vs.Format("2006-01-02")
r.VacationEnd = ve.Format("2006-01-02")
}
@@ -252,19 +312,20 @@ func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time) (adju
}
// findVacationBlock locates the contiguous vacation block that `date` lies
// in by scanning outward through non-working days. 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) (start, end time.Time, ok bool) {
// 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) {
if !s.IsNonWorkingDay(cur, country, regime) {
break
}
if h := s.IsHoliday(cur); h != nil && h.IsVacation {
if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
firstVac = cur
}
cur = cur.AddDate(0, 0, -1)
@@ -272,10 +333,10 @@ func (s *HolidayService) findVacationBlock(date time.Time) (start, end time.Time
cur = date.AddDate(0, 0, 1)
for i := 0; i < 60; i++ {
if !s.IsNonWorkingDay(cur) {
if !s.IsNonWorkingDay(cur, country, regime) {
break
}
if h := s.IsHoliday(cur); h != nil && h.IsVacation {
if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
lastVac = cur
}
cur = cur.AddDate(0, 0, 1)
@@ -298,17 +359,17 @@ 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", IsClosure: true},
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true},
{Date: easter, Name: "Ostersonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true},
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true},
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true},
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true},
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true},
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true},
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true},
{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},
}
}