Files
paliad/internal/services/courts.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

154 lines
4.5 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
"github.com/jmoiron/sqlx"
)
// ErrUnknownCourt is returned when a court ID is not found in paliad.courts.
var ErrUnknownCourt = errors.New("unknown court")
// Court is the deadline-computation slice of a court row from paliad.courts.
// The rich Gerichtsverzeichnis (addresses, languages, contacts) lives in the
// static catalog at internal/handlers/courts.go and is sibling to this entity,
// not master/replica — the static slice is what users browse, paliad.courts
// is what the deadline math joins against.
type Court struct {
ID string `db:"id" json:"id"`
Code string `db:"code" json:"code"`
NameDE string `db:"name_de" json:"nameDE"`
NameEN string `db:"name_en" json:"nameEN"`
Country string `db:"country" json:"country"` // ISO-3166 alpha-2
Regime *string `db:"regime" json:"regime,omitempty"` // 'UPC' | 'EPO' | NULL
CourtType string `db:"court_type" json:"courtType"`
ParentID *string `db:"parent_id" json:"parentId,omitempty"`
SortOrder int `db:"sort_order" json:"sortOrder"`
IsActive bool `db:"is_active" json:"isActive"`
}
// RegimeOrEmpty returns the court's regime as a plain string ("UPC" / "EPO"
// / "") so callers don't have to dereference Court.Regime.
func (c Court) RegimeOrEmpty() string {
if c.Regime == nil {
return ""
}
return *c.Regime
}
// CourtService loads + caches paliad.courts. Cache is built on first lookup
// and shared across subsequent calls; the catalog is small (~50 rows) and
// changes only via migration, so a single-shot load is fine.
type CourtService struct {
db *sqlx.DB
once sync.Once
mu sync.RWMutex
byID map[string]Court
all []Court
err error
}
// NewCourtService wires the service to the DB.
func NewCourtService(db *sqlx.DB) *CourtService {
return &CourtService{db: db}
}
func (s *CourtService) load() {
if s.db == nil {
s.err = fmt.Errorf("courts service: no DB configured")
return
}
var rows []Court
err := s.db.SelectContext(context.Background(), &rows, `
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order, is_active
FROM paliad.courts
WHERE is_active = true
ORDER BY sort_order, id`)
if err != nil {
s.err = fmt.Errorf("load courts: %w", err)
return
}
s.all = rows
s.byID = make(map[string]Court, len(rows))
for _, c := range rows {
s.byID[c.ID] = c
}
}
// ensureLoaded loads the catalog once. Subsequent calls are no-ops.
func (s *CourtService) ensureLoaded() error {
s.once.Do(s.load)
return s.err
}
// Lookup returns the court row for an ID. ErrUnknownCourt when not found.
func (s *CourtService) Lookup(id string) (Court, error) {
if id == "" {
return Court{}, ErrUnknownCourt
}
if err := s.ensureLoaded(); err != nil {
return Court{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.byID[id]
if !ok {
return Court{}, ErrUnknownCourt
}
return c, nil
}
// CountryRegime resolves a court ID to its (country, regime) tuple for
// holiday-calendar lookup. Empty courtID falls back to (defaultCountry,
// defaultRegime) so legacy callers without a court_id still get sensible
// behaviour. ErrUnknownCourt when courtID is non-empty and not in the table.
func (s *CourtService) CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) {
if courtID == "" {
return defaultCountry, defaultRegime, nil
}
c, err := s.Lookup(courtID)
if err != nil {
return "", "", err
}
return c.Country, c.RegimeOrEmpty(), nil
}
// All returns the full active court list (read-only — caller must not
// mutate). Order: sort_order asc, id asc.
func (s *CourtService) All() ([]Court, error) {
if err := s.ensureLoaded(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Court, len(s.all))
copy(out, s.all)
return out, nil
}
// ByCourtType returns all active courts of a given type (e.g. "UPC-LD").
// Useful for the Fristenrechner court-picker UI.
func (s *CourtService) ByCourtType(courtType string) ([]Court, error) {
if err := s.ensureLoaded(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Court, 0, 8)
for _, c := range s.all {
if c.CourtType == courtType {
out = append(out, c)
}
}
return out, nil
}
// guard against nil-deref when courtID is supplied by an API caller and the
// service is wired. Not used internally; exposed so handlers can return
// 404-equivalents on bad input without leaking sql errors.
var _ = sql.ErrNoRows