Files
paliad/internal/services/holidays.go
m d72990ad1b 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.
2026-05-06 12:47:12 +02:00

395 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}
// 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
}