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:
@@ -90,6 +90,7 @@ func main() {
|
||||
log.Printf("refresh deadline_search: %v", err)
|
||||
}
|
||||
holidays := services.NewHolidayService(pool)
|
||||
courts := services.NewCourtService(pool)
|
||||
users := services.NewUserService(pool)
|
||||
projectSvc := services.NewProjectService(pool, users)
|
||||
teamSvc := services.NewTeamService(pool, projectSvc)
|
||||
@@ -140,8 +141,9 @@ func main() {
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays),
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
EventType: eventTypeSvc,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/deadline-rules?proceeding_type_id=N
|
||||
@@ -65,6 +67,7 @@ func handleCalculateDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
ProceedingType string `json:"proceeding_type"`
|
||||
TriggerDate string `json:"trigger_date"`
|
||||
CourtID string `json:"court_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
@@ -86,7 +89,14 @@ func handleCalculateDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
results := dbSvc.calc.CalculateFromRules(triggerDate, rules)
|
||||
defaultCountry, defaultRegime := services.DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := dbSvc.courts.CountryRegime(input.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown court_id"})
|
||||
return
|
||||
}
|
||||
|
||||
results := dbSvc.calc.CalculateFromRules(triggerDate, rules, country, regime)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"proceeding_type": pt.Code,
|
||||
"proceeding_name": pt.Name,
|
||||
|
||||
@@ -166,6 +166,7 @@ func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TriggerEventID int64 `json:"triggerEventId"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
CourtID string `json:"courtId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -175,7 +176,7 @@ func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerEventId und triggerDate sind erforderlich"})
|
||||
return
|
||||
}
|
||||
resp, err := dbSvc.eventDeadline.Calculate(r.Context(), req.TriggerEventID, req.TriggerDate)
|
||||
resp, err := dbSvc.eventDeadline.Calculate(r.Context(), req.TriggerEventID, req.TriggerDate, req.CourtID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownTriggerEvent) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekanntes Trigger-Ereignis"})
|
||||
|
||||
@@ -61,6 +61,7 @@ type Services struct {
|
||||
EmailTemplate *services.EmailTemplateService
|
||||
Link *services.LinkService
|
||||
Event *services.EventService
|
||||
Courts *services.CourtService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -94,6 +95,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
emailTemplate: svc.EmailTemplate,
|
||||
link: svc.Link,
|
||||
event: svc.Event,
|
||||
courts: svc.Courts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ type dbServices struct {
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
153
internal/services/courts.go
Normal file
153
internal/services/courts.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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
|
||||
@@ -27,8 +27,9 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
|
||||
}
|
||||
|
||||
// CalculateEndDate applies a single rule's duration + timing to the event date,
|
||||
// then bumps forward off non-working days. Returns (adjusted, original, didAdjust).
|
||||
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule) (time.Time, time.Time, bool) {
|
||||
// then bumps forward off non-working days for the given (country, regime).
|
||||
// Returns (adjusted, original, didAdjust).
|
||||
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
|
||||
endDate := eventDate
|
||||
|
||||
timing := "after"
|
||||
@@ -51,20 +52,21 @@ func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.D
|
||||
}
|
||||
|
||||
original := endDate
|
||||
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate)
|
||||
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// CalculateFromRules calculates deadlines for a slice of rules. Rules with
|
||||
// CalculateFromRules calculates deadlines for a slice of rules using the
|
||||
// given (country, regime) for non-working-day adjustment. Rules with
|
||||
// duration == 0 (court-set hearings, decisions) return the event date itself.
|
||||
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule) []CalculatedDeadline {
|
||||
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule, country, regime string) []CalculatedDeadline {
|
||||
results := make([]CalculatedDeadline, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
var adjusted, original time.Time
|
||||
var wasAdjusted bool
|
||||
|
||||
if r.DurationValue > 0 {
|
||||
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, r)
|
||||
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, r, country, regime)
|
||||
} else {
|
||||
adjusted, original = eventDate, eventDate
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCalculateEndDate_Days(t *testing.T) {
|
||||
}
|
||||
// 2026-01-13 (Tue) + 10 days = 2026-01-23 (Fri) — no adjustment.
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if !adjusted.Equal(want) {
|
||||
@@ -52,7 +52,7 @@ func TestCalculateEndDate_Months_HolidayAdjust(t *testing.T) {
|
||||
// 2026-01-01 (Neujahr) + 3 months = 2026-04-01.
|
||||
// 2026-04-01 = Wednesday → working day, no adjust.
|
||||
in := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, _ := calc.CalculateEndDate(in, rule)
|
||||
adjusted, original, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
wantOrig := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !original.Equal(wantOrig) {
|
||||
t.Errorf("original: got %s, want %s", original, wantOrig)
|
||||
@@ -78,7 +78,7 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
|
||||
// Adjust: skip Karfreitag (Fri), Sat, Sun, Ostermontag (Mon 04-06), to
|
||||
// Tuesday 2026-04-07.
|
||||
in := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
wantOrig := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
|
||||
wantAdj := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
|
||||
// Adjust: Sunday → Monday 2026-03-16.
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
@@ -123,7 +123,7 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
|
||||
{ID: uuid.New(), Name: "Defence", Code: ptr("inf.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
|
||||
}
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
results := calc.CalculateFromRules(in, rules)
|
||||
results := calc.CalculateFromRules(in, rules, "DE", "UPC")
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("got %d results, want 2", len(results))
|
||||
@@ -155,11 +155,11 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
||||
// (working day), which doesn't exercise the cap. Skip if running without
|
||||
// the production seed.
|
||||
in := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC)
|
||||
if holidays.IsNonWorkingDay(in) {
|
||||
if holidays.IsNonWorkingDay(in, "DE", "UPC") {
|
||||
t.Skip("Thu 2026-07-30 unexpectedly flagged as non-working without UPC seed")
|
||||
}
|
||||
// Sanity: with no UPC vacation, Thu 2026-07-30 + 0 → unchanged.
|
||||
adjusted, _, wasAdjusted := holidays.AdjustForNonWorkingDays(in)
|
||||
adjusted, _, wasAdjusted := holidays.AdjustForNonWorkingDays(in, "DE", "UPC")
|
||||
if wasAdjusted {
|
||||
t.Errorf("expected no adjustment without UPC seed; got %s", adjusted)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ type EventDeadlineService struct {
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// NewEventDeadlineService wires the service to its dependencies.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService) *EventDeadlineService {
|
||||
return &EventDeadlineService{db: db, calc: calc, holidays: holidays}
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService) *EventDeadlineService {
|
||||
return &EventDeadlineService{db: db, calc: calc, holidays: holidays, courts: courts}
|
||||
}
|
||||
|
||||
// TriggerEventSummary is the shape returned to the picker UI: lightweight
|
||||
@@ -79,11 +80,21 @@ type CalculateResponse struct {
|
||||
Deadlines []EventDeadlineResult `json:"deadlines"`
|
||||
}
|
||||
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date.
|
||||
// Days/weeks/months use AddDate (calendar arithmetic). working_days uses
|
||||
// HolidayService.IsNonWorkingDay to skip weekends + holidays. Composite
|
||||
// rules (alt_* + combine_op) compute both legs and pick max/min.
|
||||
func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int64, triggerDateStr string) (*CalculateResponse, error) {
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date for
|
||||
// the given court. Days/weeks/months use AddDate (calendar arithmetic).
|
||||
// working_days uses HolidayService.IsNonWorkingDay to skip weekends +
|
||||
// holidays applicable to the court's (country, regime). Composite rules
|
||||
// (alt_* + combine_op) compute both legs and pick max/min.
|
||||
//
|
||||
// courtID may be empty for legacy callers — we default to a UPC München
|
||||
// context (DE country, UPC regime) since the trigger-event Fristenrechner
|
||||
// is UPC-flavoured today.
|
||||
func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int64, triggerDateStr, courtID string) (*CalculateResponse, error) {
|
||||
country, regime, err := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
@@ -123,7 +134,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
|
||||
results := make([]EventDeadlineResult, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
base, baseAdj, baseChanged := s.applyDuration(triggerDate, r.DurationValue, r.DurationUnit, r.Timing)
|
||||
base, baseAdj, baseChanged := s.applyDuration(triggerDate, r.DurationValue, r.DurationUnit, r.Timing, country, regime)
|
||||
|
||||
picked := baseAdj
|
||||
original := base
|
||||
@@ -132,7 +143,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
compositeNote := ""
|
||||
|
||||
if r.AltDurationValue != nil && r.AltDurationUnit != nil && r.CombineOp != nil {
|
||||
alt, altAdj, altChanged := s.applyDuration(triggerDate, *r.AltDurationValue, *r.AltDurationUnit, r.Timing)
|
||||
alt, altAdj, altChanged := s.applyDuration(triggerDate, *r.AltDurationValue, *r.AltDurationUnit, r.Timing, country, regime)
|
||||
isComposite = true
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
@@ -198,9 +209,10 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
}
|
||||
|
||||
// applyDuration computes (raw, adjusted, didAdjust) for a single leg of a
|
||||
// rule. Honours timing ('before' subtracts, 'after' adds) and routes to
|
||||
// working-day arithmetic when unit == "working_days".
|
||||
func (s *EventDeadlineService) applyDuration(triggerDate time.Time, value int, unit, timing string) (raw time.Time, adjusted time.Time, didAdjust bool) {
|
||||
// rule using the given (country, regime) for non-working-day adjustment.
|
||||
// Honours timing ('before' subtracts, 'after' adds) and routes to working-
|
||||
// day arithmetic when unit == "working_days".
|
||||
func (s *EventDeadlineService) applyDuration(triggerDate time.Time, value int, unit, timing, country, regime string) (raw time.Time, adjusted time.Time, didAdjust bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
@@ -214,7 +226,7 @@ func (s *EventDeadlineService) applyDuration(triggerDate time.Time, value int, u
|
||||
case "months":
|
||||
raw = triggerDate.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = s.addWorkingDays(triggerDate, sign*value)
|
||||
raw = s.addWorkingDays(triggerDate, sign*value, country, regime)
|
||||
default:
|
||||
raw = triggerDate
|
||||
}
|
||||
@@ -224,14 +236,14 @@ func (s *EventDeadlineService) applyDuration(triggerDate time.Time, value int, u
|
||||
if unit == "working_days" {
|
||||
return raw, raw, false
|
||||
}
|
||||
adjusted, _, didAdjust = s.holidays.AdjustForNonWorkingDays(raw)
|
||||
adjusted, _, didAdjust = s.holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
return raw, adjusted, didAdjust
|
||||
}
|
||||
|
||||
// addWorkingDays advances from `from` by `n` working days (skipping weekends
|
||||
// + holidays). Negative `n` walks backward. Returns the date that lands on
|
||||
// a working day. Caller passes the signed delta.
|
||||
func (s *EventDeadlineService) addWorkingDays(from time.Time, n int) time.Time {
|
||||
// + holidays applicable to the given country/regime). Negative `n` walks
|
||||
// backward. Returns the date that lands on a working day.
|
||||
func (s *EventDeadlineService) addWorkingDays(from time.Time, n int, country, regime string) time.Time {
|
||||
if n == 0 {
|
||||
// Day-zero convention: if the trigger itself is a non-working day,
|
||||
// don't roll forward — that's the caller's job to decide via the
|
||||
@@ -248,7 +260,7 @@ func (s *EventDeadlineService) addWorkingDays(from time.Time, n int) time.Time {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
// Walk past consecutive non-working days. Bounded loop: 30 + n is
|
||||
// a safety net; in practice we never see vacation runs > 14 days.
|
||||
for j := 0; j < 30 && s.holidays.IsNonWorkingDay(cur); j++ {
|
||||
for j := 0; j < 30 && s.holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestAddWorkingDays_SkipsWeekends(t *testing.T) {
|
||||
// (skip) → Sun (skip) → Mon May 4 = WD 1; → Tue May 5 = WD 2; → Wed
|
||||
// May 6 = WD 3. So +3 wd = Wed 2026-05-06.
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, 3)
|
||||
got := s.addWorkingDays(in, 3, "DE", "UPC")
|
||||
want := time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(+3): got %s, want %s", got, want)
|
||||
@@ -27,7 +27,7 @@ func TestAddWorkingDays_SkipsHolidays(t *testing.T) {
|
||||
// 2026-04-30 = Thu. +1 wd = Fri 2026-05-01 = Tag der Arbeit (DE federal holiday).
|
||||
// → skip → Sat (weekend) → skip → Sun (weekend) → skip → Mon 2026-05-04.
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, 1)
|
||||
got := s.addWorkingDays(in, 1, "DE", "UPC")
|
||||
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(+1) over Tag der Arbeit: got %s, want %s", got, want)
|
||||
@@ -41,7 +41,7 @@ func TestAddWorkingDays_NegativeWalksBackward(t *testing.T) {
|
||||
// Walk: -1 wd → Fri 05-01 → holiday → Thu 04-30 = working. 1 wd done.
|
||||
// -1 wd → Wed 04-29. 2 wd done.
|
||||
in := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, -2)
|
||||
got := s.addWorkingDays(in, -2, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(-2) over Tag der Arbeit: got %s, want %s", got, want)
|
||||
@@ -53,7 +53,7 @@ func TestAddWorkingDays_Zero(t *testing.T) {
|
||||
|
||||
// Day-zero convention: returns input unchanged, even if it's a weekend.
|
||||
weekend := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC) // Saturday
|
||||
got := s.addWorkingDays(weekend, 0)
|
||||
got := s.addWorkingDays(weekend, 0, "DE", "UPC")
|
||||
if !got.Equal(weekend) {
|
||||
t.Errorf("addWorkingDays(0) on weekend: got %s, want %s (unchanged)", got, weekend)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestApplyDuration_WorkingDays_SkipsAdjustment(t *testing.T) {
|
||||
// working_days lands on a working day by construction → no further adjust.
|
||||
// Thu 2026-04-30 + 1 wd = Mon 2026-05-04 (skipped Fri holiday + weekend).
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
raw, adjusted, didAdjust := s.applyDuration(in, 1, "working_days", "after")
|
||||
raw, adjusted, didAdjust := s.applyDuration(in, 1, "working_days", "after", "DE", "UPC")
|
||||
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if !raw.Equal(want) {
|
||||
@@ -84,7 +84,7 @@ func TestApplyDuration_BeforeTiming(t *testing.T) {
|
||||
|
||||
// Wed 2026-04-15 - 2 weeks = Wed 2026-04-01. Working day → no adjust.
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
raw, adjusted, _ := s.applyDuration(in, 2, "weeks", "before")
|
||||
raw, adjusted, _ := s.applyDuration(in, 2, "weeks", "before", "DE", "UPC")
|
||||
want := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !raw.Equal(want) {
|
||||
t.Errorf("raw: got %s, want %s", raw, want)
|
||||
@@ -100,8 +100,8 @@ func TestComposite_R198_LongerLegWins(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
_, baseAdj, _ := s.applyDuration(in, 31, "days", "after")
|
||||
_, altAdj, _ := s.applyDuration(in, 20, "working_days", "after")
|
||||
_, baseAdj, _ := s.applyDuration(in, 31, "days", "after", "DE", "UPC")
|
||||
_, altAdj, _ := s.applyDuration(in, 20, "working_days", "after", "DE", "UPC")
|
||||
|
||||
// 31 calendar days from Thu 2026-04-30 = Sun 2026-05-31 → adjust to Mon 2026-06-01.
|
||||
// 20 working days from Thu 2026-04-30 ≈ early June (skipping May 1 holiday + weekends).
|
||||
|
||||
@@ -24,11 +24,12 @@ import (
|
||||
type FristenrechnerService struct {
|
||||
rules *DeadlineRuleService
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// NewFristenrechnerService wires the service to its dependencies.
|
||||
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService) *FristenrechnerService {
|
||||
return &FristenrechnerService{rules: rules, holidays: holidays}
|
||||
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
|
||||
return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
|
||||
}
|
||||
|
||||
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
||||
@@ -87,6 +88,12 @@ type CalcOptions struct {
|
||||
PriorityDateStr string
|
||||
Flags []string
|
||||
AnchorOverrides map[string]string
|
||||
// CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris",
|
||||
// "de-bgh"). The calculator resolves it to (country, regime) for non-
|
||||
// working-day computation. Empty falls back to UPC München (DE/UPC) for
|
||||
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
||||
// behaviour for callers that don't yet send a court.
|
||||
CourtID string
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -149,9 +156,10 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
Jurisdiction *string `db:"jurisdiction"`
|
||||
}
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT id, code, name, name_en
|
||||
`SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, proceedingCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -161,6 +169,16 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
|
||||
}
|
||||
|
||||
// Resolve (country, regime) for non-working-day adjustment. Court wins
|
||||
// when supplied; otherwise default by proceeding regime. UPC proceedings
|
||||
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
|
||||
// DE proceedings default to DE (no supranational regime).
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := s.rules.List(ctx, &pt.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -396,7 +414,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
|
||||
endDate := addDuration(baseDate, durationValue, durationUnit)
|
||||
origDate := endDate
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate, country, regime)
|
||||
|
||||
d.OriginalDate = origDate.Format("2006-01-02")
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
@@ -430,6 +448,7 @@ type CalcRuleParams struct {
|
||||
RuleLocalCode string // optional — paliad.deadline_rules.code
|
||||
TriggerDate string // required — YYYY-MM-DD
|
||||
Flags []string // optional — condition_flag inputs
|
||||
CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction
|
||||
}
|
||||
|
||||
// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
|
||||
@@ -596,8 +615,14 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
return out, nil
|
||||
}
|
||||
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := s.courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
||||
}
|
||||
|
||||
endDate := addDuration(triggerDate, durationValue, durationUnit)
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate, country, regime)
|
||||
out.OriginalDate = endDate.Format("2006-01-02")
|
||||
out.DueDate = adjusted.Format("2006-01-02")
|
||||
out.WasAdjusted = wasAdj
|
||||
@@ -753,3 +778,26 @@ func addDuration(base time.Time, value int, unit string) time.Time {
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
|
||||
// holiday lookup should default to when the caller didn't pass an explicit
|
||||
// CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common
|
||||
// venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA
|
||||
// get DE-only (German federal). Future EPA-specific closures will require
|
||||
// callers to pick an EPA court explicitly so the EPO regime kicks in.
|
||||
//
|
||||
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
||||
// these defaults are bypassed entirely and the court's actual country +
|
||||
// regime are used.
|
||||
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
||||
if jurisdiction == nil {
|
||||
return CountryDE, ""
|
||||
}
|
||||
switch *jurisdiction {
|
||||
case "UPC":
|
||||
return CountryDE, RegimeUPC
|
||||
default:
|
||||
return CountryDE, ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ func TestCalculateRule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
holidays := NewHolidayService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays)
|
||||
courts := NewCourtService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
t.Run("plain rule calc — UPC_INF inf.sod, R.23(1), 3 months", func(t *testing.T) {
|
||||
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
|
||||
|
||||
@@ -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,21 +114,34 @@ 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 {
|
||||
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] {
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,15 +56,15 @@ func TestIsNonWorkingDay_NoDB(t *testing.T) {
|
||||
s := NewHolidayService(nil)
|
||||
|
||||
// 2026-01-01 is Neujahr (Thursday)
|
||||
if !s.IsNonWorkingDay(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
if !s.IsNonWorkingDay(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
|
||||
t.Error("Neujahr 2026 should be non-working")
|
||||
}
|
||||
// Saturday: 2026-01-03
|
||||
if !s.IsNonWorkingDay(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)) {
|
||||
if !s.IsNonWorkingDay(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
|
||||
t.Error("Saturday should be non-working")
|
||||
}
|
||||
// Regular Tuesday: 2026-01-13
|
||||
if s.IsNonWorkingDay(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)) {
|
||||
if s.IsNonWorkingDay(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
|
||||
t.Error("regular Tuesday should be working")
|
||||
}
|
||||
}
|
||||
@@ -73,14 +73,14 @@ func TestAdjustForNonWorkingDays_NoDB(t *testing.T) {
|
||||
s := NewHolidayService(nil)
|
||||
|
||||
// 2026-01-01 (Neujahr Thu) → 2026-01-02 (Fri, working)
|
||||
got, _, adjusted := s.AdjustForNonWorkingDays(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
got, _, adjusted := s.AdjustForNonWorkingDays(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC")
|
||||
want := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) || !adjusted {
|
||||
t.Errorf("Neujahr: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
|
||||
}
|
||||
|
||||
// Saturday 2026-01-03 → Monday 2026-01-05
|
||||
got, _, adjusted = s.AdjustForNonWorkingDays(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC))
|
||||
got, _, adjusted = s.AdjustForNonWorkingDays(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC")
|
||||
want = time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) || !adjusted {
|
||||
t.Errorf("Saturday: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
|
||||
@@ -88,7 +88,7 @@ func TestAdjustForNonWorkingDays_NoDB(t *testing.T) {
|
||||
|
||||
// Regular Tuesday 2026-01-13 → unchanged
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
got, _, adjusted = s.AdjustForNonWorkingDays(in)
|
||||
got, _, adjusted = s.AdjustForNonWorkingDays(in, "DE", "UPC")
|
||||
if !got.Equal(in) || adjusted {
|
||||
t.Errorf("Tuesday: got %s adjusted=%v, want %s adjusted=false", got, adjusted, in)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestAdjustForNonWorkingDaysWithReason_Weekend(t *testing.T) {
|
||||
|
||||
// Saturday 2026-01-03 → Monday 2026-01-05, Kind = "weekend".
|
||||
in := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
|
||||
if !wasAdj || reason == nil {
|
||||
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestAdjustForNonWorkingDaysWithReason_PublicHoliday(t *testing.T) {
|
||||
// hardcoded set; Holidays should contain at least Karfreitag and
|
||||
// Ostermontag (Ostersonntag too — it is a federal holiday entry).
|
||||
in := time.Date(2025, 4, 18, 0, 0, 0, 0, time.UTC)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
|
||||
if !wasAdj || reason == nil {
|
||||
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
if wd := d.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
continue // matches migration 010 — weekdays only
|
||||
}
|
||||
holidays = append(holidays, Holiday{Date: d, Name: name, IsVacation: true})
|
||||
holidays = append(holidays, Holiday{Date: d, Name: name, Regime: RegimeUPC, IsVacation: true})
|
||||
}
|
||||
}
|
||||
addVacationRun("UPC Summer Vacation",
|
||||
@@ -195,13 +195,13 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
// (a) Tue 2026-08-04 — m's reproduction. Inside UPC summer vacation,
|
||||
// but a regular working Tuesday for the Court → no shift.
|
||||
summerWeekday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
if s.IsNonWorkingDay(summerWeekday) {
|
||||
if s.IsNonWorkingDay(summerWeekday, "DE", "UPC") {
|
||||
t.Errorf("2026-08-04 (UPC summer vacation, Tue): IsNonWorkingDay=true, want false")
|
||||
}
|
||||
if h := s.IsHoliday(summerWeekday); h == nil || !h.IsVacation {
|
||||
if h := s.IsHoliday(summerWeekday, "DE", "UPC"); h == nil || !h.IsVacation {
|
||||
t.Errorf("2026-08-04: IsHoliday should still surface the vacation entry, got %v", h)
|
||||
}
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(summerWeekday)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(summerWeekday, "DE", "UPC")
|
||||
if wasAdj || reason != nil {
|
||||
t.Errorf("2026-08-04: wasAdj=%v reason=%v, want no adjustment", wasAdj, reason)
|
||||
}
|
||||
@@ -212,10 +212,10 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
// (b) Mon 2026-12-28 — inside UPC winter vacation, but a working Monday
|
||||
// between Christmas and Neujahr for the Court → no shift.
|
||||
winterWeekday := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
|
||||
if s.IsNonWorkingDay(winterWeekday) {
|
||||
if s.IsNonWorkingDay(winterWeekday, "DE", "UPC") {
|
||||
t.Errorf("2026-12-28 (UPC winter vacation, Mon): IsNonWorkingDay=true, want false")
|
||||
}
|
||||
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(winterWeekday)
|
||||
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(winterWeekday, "DE", "UPC")
|
||||
if wasAdj || !adj.Equal(winterWeekday) {
|
||||
t.Errorf("2026-12-28: wasAdj=%v adjusted=%s, want no shift from %s", wasAdj, adj, winterWeekday)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
// 2026-12-28. Mon 28 IS in UPC winter vacation, but that entry is
|
||||
// informational only (IsClosure=false) so the walk stops there.
|
||||
christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
|
||||
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(christmas)
|
||||
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(christmas, "DE", "UPC")
|
||||
if !wasAdj || reason == nil {
|
||||
t.Fatalf("Christmas 2026: expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
|
||||
}
|
||||
@@ -242,10 +242,10 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
// are informational only, so the shift stops at the next non-
|
||||
// weekend / non-closure day.
|
||||
neujahr := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !s.IsNonWorkingDay(neujahr) {
|
||||
if !s.IsNonWorkingDay(neujahr, "DE", "UPC") {
|
||||
t.Errorf("Neujahr 2027: IsNonWorkingDay=false, want true")
|
||||
}
|
||||
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(neujahr)
|
||||
adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(neujahr, "DE", "UPC")
|
||||
wantNeujahr := time.Date(2027, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
if !wasAdj || !adj.Equal(wantNeujahr) {
|
||||
t.Errorf("Neujahr 2027: adjusted=%s wasAdj=%v, want %s adjusted=true", adj, wasAdj, wantNeujahr)
|
||||
@@ -254,7 +254,7 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
// (e) Karfreitag 2026 (Fri 3 Apr) regression — DE public_holiday outside
|
||||
// any UPC vacation, must still shift to Tue 2026-04-07 (Easter Mon).
|
||||
karfreitag := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
|
||||
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(karfreitag)
|
||||
adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(karfreitag, "DE", "UPC")
|
||||
if !wasAdj || reason == nil || reason.Kind != "public_holiday" {
|
||||
t.Errorf("Karfreitag 2026: wasAdj=%v reason=%v, want public_holiday", wasAdj, reason)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func TestVacationDoesNotShiftDeadlines(t *testing.T) {
|
||||
func TestAdjustForNonWorkingDaysWithReason_NoShift(t *testing.T) {
|
||||
s := NewHolidayService(nil)
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) // regular Tuesday
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
|
||||
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
|
||||
if wasAdj || reason != nil {
|
||||
t.Errorf("regular Tuesday should not adjust; got wasAdj=%v reason=%v", wasAdj, reason)
|
||||
}
|
||||
@@ -295,3 +295,94 @@ func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// t-paliad-122: per-country / per-regime filtering. Same Holiday cache, but
|
||||
// (country, regime) selects which entries apply. Confirms the design:
|
||||
// - DE court (DE, "") → German federal only.
|
||||
// - UPC LD München (DE, UPC) → DE federal + UPC vacations.
|
||||
// - UPC LD Paris (FR, UPC) → no DE, gets UPC vacations + (eventually) FR.
|
||||
// - LG München (DE, "") → German federal only, no UPC vacations.
|
||||
func TestAppliesTo_CountryRegimeFilter(t *testing.T) {
|
||||
s := NewHolidayService(nil)
|
||||
|
||||
// Inject a curated cache: DE federal + UPC summer vacation + an FR row.
|
||||
rows := make([]Holiday, 0, 15)
|
||||
rows = append(rows, germanFederalHolidays(2026)...)
|
||||
rows = append(rows, Holiday{
|
||||
Date: time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC),
|
||||
Name: "UPC Summer Vacation",
|
||||
Regime: RegimeUPC,
|
||||
IsVacation: true,
|
||||
})
|
||||
rows = append(rows, Holiday{
|
||||
Date: time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC),
|
||||
Name: "Fête nationale",
|
||||
Country: "FR",
|
||||
IsClosure: true,
|
||||
})
|
||||
entry := &yearEntry{holidays: rows}
|
||||
entry.once.Do(func() {})
|
||||
s.cache.Store(2026, entry)
|
||||
|
||||
// Christmas: applies to anyone with country=DE.
|
||||
christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
|
||||
if h := s.IsHoliday(christmas, "DE", ""); h == nil || !h.IsClosure {
|
||||
t.Errorf("DE court / Christmas: want closure, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(christmas, "DE", "UPC"); h == nil || !h.IsClosure {
|
||||
t.Errorf("UPC LD München / Christmas: want closure, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(christmas, "FR", "UPC"); h != nil {
|
||||
t.Errorf("UPC LD Paris / DE Christmas: want no match, got %v", h)
|
||||
}
|
||||
|
||||
// UPC summer vacation row: only UPC-regime queries see it.
|
||||
summerVac := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
if h := s.IsHoliday(summerVac, "DE", ""); h != nil {
|
||||
t.Errorf("LG München (DE only) / UPC vacation date: want no match, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(summerVac, "DE", "UPC"); h == nil || !h.IsVacation {
|
||||
t.Errorf("UPC LD München / UPC vacation: want vacation, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(summerVac, "FR", "UPC"); h == nil || !h.IsVacation {
|
||||
t.Errorf("UPC LD Paris / UPC vacation: want vacation, got %v", h)
|
||||
}
|
||||
|
||||
// FR row: only FR queries see it.
|
||||
bastille := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC)
|
||||
if h := s.IsHoliday(bastille, "DE", "UPC"); h != nil {
|
||||
t.Errorf("UPC LD München / Bastille Day: want no match, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(bastille, "FR", "UPC"); h == nil || !h.IsClosure {
|
||||
t.Errorf("UPC LD Paris / Bastille Day: want closure, got %v", h)
|
||||
}
|
||||
if h := s.IsHoliday(bastille, "FR", ""); h == nil || !h.IsClosure {
|
||||
t.Errorf("FR national court / Bastille Day: want closure, got %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// AppliesTo behaviour at the unit level — pinning the matching rule that
|
||||
// drives every filter call above. Cheap regression net for future tweaks.
|
||||
func TestAppliesTo_Rules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
holiday Holiday
|
||||
country string
|
||||
regime string
|
||||
want bool
|
||||
}{
|
||||
{"DE row, DE court", Holiday{Country: "DE"}, "DE", "", true},
|
||||
{"DE row, FR court", Holiday{Country: "DE"}, "FR", "", false},
|
||||
{"UPC row, DE court (no regime)", Holiday{Regime: "UPC"}, "DE", "", false},
|
||||
{"UPC row, UPC LD München (DE, UPC)", Holiday{Regime: "UPC"}, "DE", "UPC", true},
|
||||
{"UPC row, UPC LD Paris (FR, UPC)", Holiday{Regime: "UPC"}, "FR", "UPC", true},
|
||||
{"DE+UPC overlap (UPC vacation seeded with country=DE)", Holiday{Country: "DE", Regime: "UPC"}, "FR", "UPC", true},
|
||||
{"empty row never matches", Holiday{}, "DE", "UPC", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := tc.holiday.AppliesTo(tc.country, tc.regime)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: AppliesTo(%q, %q) = %v, want %v", tc.name, tc.country, tc.regime, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user