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

316 lines
11 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
// EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode:
// given a trigger event + date, return all deadlines that flow from it
// with their computed due dates. Mirrors youpc.org's deadline-calc shape
// (event-driven), distinct from the proceeding-tree-driven Fristenrechner.
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, courts *CourtService) *EventDeadlineService {
return &EventDeadlineService{db: db, calc: calc, holidays: holidays, courts: courts}
}
// TriggerEventSummary is the shape returned to the picker UI: lightweight
// (no description, no audit timestamps) and sorted alphabetically by name.
type TriggerEventSummary struct {
ID int64 `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameDE string `db:"name_de" json:"name_de"`
}
// ErrUnknownTriggerEvent is returned when a request references a trigger
// event that doesn't exist or is inactive.
var ErrUnknownTriggerEvent = errors.New("unknown trigger event")
// ListTriggerEvents returns active triggers for the picker, sorted by name.
func (s *EventDeadlineService) ListTriggerEvents(ctx context.Context) ([]TriggerEventSummary, error) {
var rows []TriggerEventSummary
err := s.db.SelectContext(ctx, &rows, `
SELECT id, code, name, name_de
FROM paliad.trigger_events
WHERE is_active = true
ORDER BY lower(name)`)
if err != nil {
return nil, fmt.Errorf("list trigger events: %w", err)
}
return rows, nil
}
// EventDeadlineResult is one computed deadline returned to the UI.
// Bilingual title + bilingual notes + the rule codes attached to the
// deadline + the computed due date (after weekend/holiday rollover).
type EventDeadlineResult struct {
ID int64 `json:"id"`
Title string `json:"title"`
TitleDE string `json:"titleDE"`
DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"`
Timing string `json:"timing"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
RuleCodes []string `json:"ruleCodes"`
DueDate string `json:"dueDate"` // YYYY-MM-DD, after holiday/weekend adjust
OriginalDueDate string `json:"originalDueDate"` // YYYY-MM-DD, before adjust
WasAdjusted bool `json:"wasAdjusted"`
IsComposite bool `json:"isComposite,omitempty"` // true when alt_* + combine_op resolved this row
CompositeNote string `json:"compositeNote,omitempty"`
}
// CalculateResponse is what the API hands back to the client.
type CalculateResponse struct {
TriggerEvent TriggerEventSummary `json:"triggerEvent"`
TriggerDate string `json:"triggerDate"`
Deadlines []EventDeadlineResult `json:"deadlines"`
}
// 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)
}
var trig TriggerEventSummary
err = s.db.GetContext(ctx, &trig, `
SELECT id, code, name, name_de
FROM paliad.trigger_events
WHERE id = $1 AND is_active = true`, triggerEventID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownTriggerEvent
}
if err != nil {
return nil, fmt.Errorf("load trigger event: %w", err)
}
var rows []eventDeadlineRow
err = s.db.SelectContext(ctx, &rows, `
SELECT id, title, title_de, duration_value, duration_unit, timing,
notes, notes_en, alt_duration_value, alt_duration_unit, combine_op
FROM paliad.event_deadlines
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY id`, triggerEventID)
if err != nil {
return nil, fmt.Errorf("load deadlines: %w", err)
}
ids := make([]int64, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
codes, err := s.loadRuleCodes(ctx, ids)
if err != nil {
return nil, err
}
results := make([]EventDeadlineResult, 0, len(rows))
for _, r := range rows {
base, baseAdj, baseChanged := s.applyDuration(triggerDate, r.DurationValue, r.DurationUnit, r.Timing, country, regime)
picked := baseAdj
original := base
wasAdjusted := baseChanged
isComposite := false
compositeNote := ""
if r.AltDurationValue != nil && r.AltDurationUnit != nil && r.CombineOp != nil {
alt, altAdj, altChanged := s.applyDuration(triggerDate, *r.AltDurationValue, *r.AltDurationUnit, r.Timing, country, regime)
isComposite = true
switch *r.CombineOp {
case "max":
if altAdj.After(baseAdj) {
picked = altAdj
original = alt
wasAdjusted = altChanged
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
r.DurationValue, r.DurationUnit,
*r.AltDurationValue, *r.AltDurationUnit,
*r.AltDurationUnit)
} else {
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
r.DurationValue, r.DurationUnit,
*r.AltDurationValue, *r.AltDurationUnit,
r.DurationUnit)
}
case "min":
if altAdj.Before(baseAdj) {
picked = altAdj
original = alt
wasAdjusted = altChanged
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
r.DurationValue, r.DurationUnit,
*r.AltDurationValue, *r.AltDurationUnit,
*r.AltDurationUnit)
} else {
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
r.DurationValue, r.DurationUnit,
*r.AltDurationValue, *r.AltDurationUnit,
r.DurationUnit)
}
}
}
notesEN := ""
if r.NotesEN != nil {
notesEN = *r.NotesEN
}
results = append(results, EventDeadlineResult{
ID: r.ID,
Title: r.Title,
TitleDE: r.TitleDE,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
Timing: r.Timing,
Notes: r.Notes,
NotesEN: notesEN,
RuleCodes: codes[r.ID],
DueDate: picked.Format("2006-01-02"),
OriginalDueDate: original.Format("2006-01-02"),
WasAdjusted: wasAdjusted,
IsComposite: isComposite,
CompositeNote: compositeNote,
})
}
return &CalculateResponse{
TriggerEvent: trig,
TriggerDate: triggerDateStr,
Deadlines: results,
}, nil
}
// applyDuration computes (raw, adjusted, didAdjust) for a single leg of a
// 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
}
switch unit {
case "days":
raw = triggerDate.AddDate(0, 0, sign*value)
case "weeks":
raw = triggerDate.AddDate(0, 0, sign*value*7)
case "months":
raw = triggerDate.AddDate(0, sign*value, 0)
case "working_days":
raw = s.addWorkingDays(triggerDate, sign*value, country, regime)
default:
raw = triggerDate
}
// Calendar units (days/weeks/months) need post-rollover off non-working
// days. working_days lands on a working day by construction.
if unit == "working_days" {
return raw, raw, false
}
adjusted, _, didAdjust = s.holidays.AdjustForNonWorkingDays(raw, country, regime)
return raw, adjusted, didAdjust
}
// addWorkingDays advances from `from` by `n` working days (skipping weekends
// + 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
// regular AdjustForNonWorkingDays path.
return from
}
step := 1
if n < 0 {
step = -1
n = -n
}
cur := from
for i := 0; i < n; i++ {
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, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}
// eventDeadlineRow is the package-private row shape used by Calculate's
// SELECT. Keeps optional fields as pointers (nil = no composite alt-leg).
type eventDeadlineRow struct {
ID int64 `db:"id"`
Title string `db:"title"`
TitleDE string `db:"title_de"`
DurationValue int `db:"duration_value"`
DurationUnit string `db:"duration_unit"`
Timing string `db:"timing"`
Notes string `db:"notes"`
NotesEN *string `db:"notes_en"`
AltDurationValue *int `db:"alt_duration_value"`
AltDurationUnit *string `db:"alt_duration_unit"`
CombineOp *string `db:"combine_op"`
}
// loadRuleCodes batches one query for all deadline IDs.
func (s *EventDeadlineService) loadRuleCodes(ctx context.Context, ids []int64) (map[int64][]string, error) {
if len(ids) == 0 {
return map[int64][]string{}, nil
}
type codeRow struct {
EventDeadlineID int64 `db:"event_deadline_id"`
RuleCode string `db:"rule_code"`
}
var crs []codeRow
q, args, err := sqlx.In(`
SELECT event_deadline_id, rule_code
FROM paliad.event_deadline_rule_codes
WHERE event_deadline_id IN (?)
ORDER BY event_deadline_id, sort_order, rule_code`, ids)
if err != nil {
return nil, fmt.Errorf("build rule_code query: %w", err)
}
q = s.db.Rebind(q)
if err := s.db.SelectContext(ctx, &crs, q, args...); err != nil {
return nil, fmt.Errorf("load rule codes: %w", err)
}
out := make(map[int64][]string, len(ids))
for _, c := range crs {
out[c.EventDeadlineID] = append(out[c.EventDeadlineID], c.RuleCode)
}
return out, nil
}