Files
paliad/internal/services/holidays.go
m d00974424f fix(t-paliad-086): Tier 1 Fristenrechner bug fixes — PR-3
Implements the four audit recommendations from §6.1 of
docs/audit-fristenrechner-completeness-2026-04-30.md plus a holiday-
adjustment cap fix surfaced by PR-2's smoke test.

(1) UPC_INF CCR-conditional rejoinder
   Public Fristenrechner now flips inf.reply (RoP.029.b → RoP.029.a) and
   inf.rejoin (1mo / RoP.029.c → 2mo / RoP.029.d) when the user ticks
   "Mit Widerklage auf Nichtigkeit." Implemented via a new
   `condition_flag` column on paliad.deadline_rules: when the rule names
   a flag and the API request's flags array contains it, the calculator
   substitutes alt_duration_value/unit and alt_rule_code. Independent of
   the existing `condition_rule_id` mechanism (which references a real
   rule in the same proceeding tree — only useful for matter-attached
   trees that already seed the CCR rule).

(2) UPC_APP / internal APP grounds anchoring
   `app.grounds` is now anchored on the trigger date (the appealed
   decision) with a 4-month duration, not chained 2mo after `app.notice`.
   Per RoP 220.1 the legal rule is "4 months from notification of the
   decision," independent of when the notice itself was filed. The chain
   only happened to give the right answer when both legs landed on a
   working day; under holiday rollover (e.g. notice deadline pushed to
   Monday) the grounds deadline drifted off the 4mo legal target.

(3) EP_GRANT publish anchor on priority date
   New `anchor_alt` column on paliad.deadline_rules. ep_grant.publish
   carries `anchor_alt='priority_date'`. The Fristenrechner UI surfaces
   an optional "Prioritätstag" input (visible only when EP_GRANT is
   selected) that, when populated, anchors the publish-A1 calculation on
   the priority date instead of the filing. Falls back to filing date
   when the priority field is empty (the case for purely-EP applications
   with no foreign priority claim).

(4) Rule-code format normalisation
   Migration 029 normalises 'RoP 23' → 'RoP.023', 'RoP 29b' / 'RoP.029b'
   → 'RoP.029.b', 'RoP 220.1' → 'RoP.220.1', etc. across deadline_rules.
   Matches the canonical youpc format already used by the PR-1 imported
   event-deadline rule codes.

(+) AdjustForNonWorkingDays cap bumped 30 → 60
   Surfaced by the PR-2 smoke test: SoD on 2026-04-30 (3mo from trigger)
   landed on Sat 2026-08-29 instead of Mon 2026-08-31. The 30-iteration
   safety bound on AdjustForNonWorkingDays cannot walk past the 33-day
   UPC summer vacation plus flanking weekends. Bumped to 60. Pure-Go
   one-liner, locked by a follow-up production smoke (real
   paliad.holidays seed has the UPC vacation).

Schema (migration 029): two new nullable text columns on
paliad.deadline_rules — `condition_flag` and `anchor_alt`. Both ignored
by every existing rule; only the rows updated above carry values.

Models: DeadlineRule gains ConditionFlag + AnchorAlt (nilable strings).

Service: FristenrechnerService.Calculate now takes a CalcOptions struct
(PriorityDateStr, Flags). API handler accepts optional priorityDate and
flags fields on POST /api/tools/fristenrechner.

Frontend: TSX surfaces the priority-date row + CCR checkbox conditionally
on selectedType (only EP_GRANT / UPC_INF respectively). Client TS reads
them and threads through the API call. New i18n keys for both DE+EN.

Migration 029 dry-run validated on prod Supabase (BEGIN/ROLLBACK):
schema + UPDATEs apply cleanly, rule states match expected post-fix
shape. Tests + go build/vet + bun build all clean.
2026-04-30 11:11:47 +02:00

182 lines
6.1 KiB
Go

// Package services holds the Paliad domain services backed by paliad.* tables.
package services
import (
"context"
"fmt"
"sync"
"time"
"github.com/jmoiron/sqlx"
)
// Holiday is a non-working day. Mirrors paliad.holidays + the German federal
// hardcoded set used as a fallback when the DB lookup misses.
type Holiday struct {
Date time.Time
Name string
IsVacation bool // part of court vacation period
IsClosure bool // single-day closure (public holiday)
}
// 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.
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"`
State *string `db:"state"`
HolidayType string `db:"holiday_type"`
}
// LoadHolidaysForYear loads holidays for a year (cached, race-safe).
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, 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 {
holidays = append(holidays, Holiday{
Date: h.Date,
Name: h.Name,
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.
seen := make(map[string]bool, len(holidays))
for _, h := range holidays {
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, else nil.
func (s *HolidayService) IsHoliday(date time.Time) *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]
}
}
return nil
}
// IsNonWorkingDay returns true on weekends or holidays.
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
return true
}
return s.IsHoliday(date) != nil
}
// AdjustForNonWorkingDays moves the date forward to the next working day.
// Returns adjusted date, the original (unmodified) date, and whether any
// adjustment was made.
//
// The 60-iteration safety bound has to span the longest run of consecutive
// non-working days that paliad ever sees: UPC summer vacation (~33 days) +
// flanking weekends + a German federal holiday on the trailing edge. Pre-
// PR-3 the bound was 30, which silently bailed mid-vacation onto a Saturday
// — caught by the t-paliad-086 PR-2 smoke test (Statement of Defence on
// 2026-04-30 → adjusted incorrectly to 2026-08-29 instead of 2026-08-31).
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// 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", 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},
}
}
// 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
}