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.
395 lines
14 KiB
Go
395 lines
14 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
|
||
}
|
||
|
||
// 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
|
||
}
|