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.
316 lines
11 KiB
Go
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
|
|
}
|