Files
paliad/internal/services/holidays.go
mAi 5f0a85fa83
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124)
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.

Package contents (~1850 LoC):
- doc.go              package docstring + reuse manifesto
- types.go            Rule, ProceedingType, NullableJSON, AdjustmentReason,
                      HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
                      TimelineEntry, RuleCalculation*, FristenrechnerType,
                      ProjectHint, sentinel errors
- catalog.go          Catalog interface (proceeding + rule lookups)
- holidays.go         HolidayCalendar interface
- courts.go           CourtRegistry interface + DefaultsForJurisdiction +
                      country/regime constants
- expr.go             EvalConditionExpr + HasConditionExpr +
                      ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go        ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go         SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go     FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go  MapLitigationToFristenrechner + code constants
                      (CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go           Calculate + CalculateRule + the trigger-event
                      branch + applyRuleOverrides (the big move)

paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
  (thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
  become type aliases to litigationplanner.* — every sqlx scan and
  every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
  aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
  of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
  + BuildLegalSourceURL replaced with delegating wrappers to lp.

Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
  service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
  time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.

Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.

Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.

Refs: docs/design-litigation-planner-2026-05-26.md
2026-05-26 13:01:07 +02:00

392 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"
"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
}