// Package services holds the Paliad domain services backed by paliad.* tables. package services import ( "context" "fmt" "sync" "time" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // 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. // // Canonical AdjustmentReason + HolidayDTO definitions live in // pkg/litigationplanner — kept here as type aliases so every existing // reference (HolidayService methods, JSON serialisation, projection // service) continues to compile. type ( AdjustmentReason = litigationplanner.AdjustmentReason HolidayDTO = litigationplanner.HolidayDTO ) // 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 }