diff --git a/cmd/server/main.go b/cmd/server/main.go index 5a71484..9da1daa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -147,7 +147,13 @@ func main() { Calculator: services.NewDeadlineCalculator(holidays), Users: users, Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts), - EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts), + EventDeadline: services.NewEventDeadlineService( + pool, + services.NewDeadlineCalculator(holidays), + holidays, + courts, + services.NewFristenrechnerService(rules, holidays, courts), + ), Courts: courts, DeadlineSearch: services.NewDeadlineSearchService(pool), EventCategory: nil, // wired below; cross-link order matters diff --git a/internal/services/event_deadline_service.go b/internal/services/event_deadline_service.go index ecd35a9..51bddc0 100644 --- a/internal/services/event_deadline_service.go +++ b/internal/services/event_deadline_service.go @@ -12,18 +12,40 @@ import ( // 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. +// with their computed due dates. Mirrors youpc.org's deadline-calc +// shape (event-driven). +// +// Phase 3 Slice 3 (t-paliad-184) refactor: the math + rule SELECT moved +// into FristenrechnerService.calculateByTriggerEvent (which reads from +// the unified paliad.deadline_rules backed by mig 085's data-move). +// EventDeadlineService.Calculate now delegates and wraps the unified +// response in the legacy CalculateResponse shape (trigger metadata + +// per-deadline rule_codes from event_deadline_rule_codes). The public +// signature stays unchanged so /api/tools/event-deadlines callers see +// no diff. The legacy applyDuration / addWorkingDays helpers stay on +// this service for the unit tests that exercise them directly; Slice 4 +// will collapse those into the unified helper. type EventDeadlineService struct { - db *sqlx.DB - calc *DeadlineCalculator - holidays *HolidayService - courts *CourtService + db *sqlx.DB + calc *DeadlineCalculator + holidays *HolidayService + courts *CourtService + fristenrechner *FristenrechnerService } -// 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} +// NewEventDeadlineService wires the service to its dependencies. The +// fristenrechner is the Phase 3 delegate target — pre-Slice-3 wiring +// can pass nil there and the legacy SELECT path is still used at +// runtime via the (currently unreachable) fallback below; today every +// caller supplies it. +func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService, fristenrechner *FristenrechnerService) *EventDeadlineService { + return &EventDeadlineService{ + db: db, + calc: calc, + holidays: holidays, + courts: courts, + fristenrechner: fristenrechner, + } } // TriggerEventSummary is the shape returned to the picker UI: lightweight @@ -80,28 +102,28 @@ type CalculateResponse struct { 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. +// Calculate resolves all deadlines flowing from a trigger event + date. // -// 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. +// Phase 3 Slice 3 (t-paliad-184) delegates the rule SELECT + math to +// FristenrechnerService.calculateByTriggerEvent — which reads from +// paliad.deadline_rules WHERE trigger_event_id = X (the rows mig 085 +// moved out of event_deadlines). This method now owns the wrapping +// concerns: trigger-event metadata lookup, rule_code aggregation (via +// the still-readable event_deadline_rule_codes junction), and the +// composite-rule note string that the legacy /api/tools/event-deadlines +// contract emits. +// +// The legacy event_deadlines table is the source-of-truth for +// (durationValue, durationUnit, timing, notes_en, alt_*, combine_op, +// id) until Slice 9 drops it. Reading those fields here keeps the +// frontend's EventDeadlineResult shape pixel-identical with pre-Slice-3 +// — verified by the 77-row parity test in event_deadline_service_test.go. +// +// courtID may be empty for legacy callers — defaults to UPC München +// (DE country, UPC regime) for the trigger-event surface. 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, ` + err := s.db.GetContext(ctx, &trig, ` SELECT id, code, name, name_de FROM paliad.trigger_events WHERE id = $1 AND is_active = true`, triggerEventID) @@ -112,6 +134,10 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int return nil, fmt.Errorf("load trigger event: %w", err) } + // Source-of-truth columns the unified UIResponse drops (the + // frontend still reads DurationValue/Unit/Timing literally to render + // the "X days after" pill). SELECT from event_deadlines is still + // allowed — the mig 086 read-only trigger only blocks writes. var rows []eventDeadlineRow err = s.db.SelectContext(ctx, &rows, ` SELECT id, title, title_de, duration_value, duration_unit, timing, @@ -124,78 +150,89 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int } ids := make([]int64, 0, len(rows)) + byTitleDE := make(map[string]eventDeadlineRow, len(rows)) for _, r := range rows { ids = append(ids, r.ID) + byTitleDE[r.TitleDE] = r } 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) + // Delegate to the unified calculator. UIResponse comes back with the + // adjusted/original dates + wasAdjusted; the per-rule metadata is + // the same names + ordering the source rows above carry, so we can + // merge them on .Name (which mig 085 copied from event_deadlines.title_de). + unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{ + TriggerEventIDFilter: &triggerEventID, + CourtID: courtID, + }) + if err != nil { + return nil, err + } - picked := baseAdj - original := base - wasAdjusted := baseChanged - isComposite := false + results := make([]EventDeadlineResult, 0, len(unified.Deadlines)) + for _, d := range unified.Deadlines { + src, ok := byTitleDE[d.Name] + if !ok { + // Defensive: a unified row exists for which no source + // event_deadlines row matches by title_de. Either a hand- + // inserted Pipeline-C rule (post-Slice-3) without a source + // counterpart, or a name divergence. Skip it from the legacy + // shape and let the parity test surface the mismatch. + continue + } + isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil 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 { + if isComposite { + // Recompute which leg won by re-running applyDuration with + // the source's exact inputs — cheaper than threading the + // pick through the unified UIDeadline shape. + country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC) + if cerr != nil { + return nil, cerr + } + triggerDate, terr := time.Parse("2006-01-02", triggerDateStr) + if terr != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr) + } + _, baseAdj, _ := s.applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime) + _, altAdj, _ := s.applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime) + pickedUnit := src.DurationUnit + switch *src.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) + pickedUnit = *src.AltDurationUnit } 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) + pickedUnit = *src.AltDurationUnit } } + compositeNote = fmt.Sprintf("%s(%d %s, %d %s) → %s leg", + *src.CombineOp, + src.DurationValue, src.DurationUnit, + *src.AltDurationValue, *src.AltDurationUnit, + pickedUnit) } - notesEN := "" - if r.NotesEN != nil { - notesEN = *r.NotesEN + if src.NotesEN != nil { + notesEN = *src.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, + ID: src.ID, + Title: src.Title, + TitleDE: src.TitleDE, + DurationValue: src.DurationValue, + DurationUnit: src.DurationUnit, + Timing: src.Timing, + Notes: src.Notes, NotesEN: notesEN, - RuleCodes: codes[r.ID], - DueDate: picked.Format("2006-01-02"), - OriginalDueDate: original.Format("2006-01-02"), - WasAdjusted: wasAdjusted, + RuleCodes: codes[src.ID], + DueDate: d.DueDate, + OriginalDueDate: d.OriginalDate, + WasAdjusted: d.WasAdjusted, IsComposite: isComposite, CompositeNote: compositeNote, })