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:
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user