Files
paliad/internal/services/event_deadline_service.go
mAi 29a6b58747 refactor(t-paliad-199): Slice 9 follow-up A — drop legacy event_deadlines tables
EventDeadlineService.Calculate now reads source rows from
paliad.deadline_rules directly (WHERE trigger_event_id IS NOT NULL),
joining via UUID instead of title_de string. The legacy SELECTs against
paliad.event_deadlines + paliad.event_deadline_rule_codes are gone.

Migration 092:
- Snapshots both legacy tables into _pre_092 audit anchors.
- Adds paliad.deadline_rules.rule_codes text[] and backfills the 72
  multi-code citations from event_deadline_rule_codes via the
  sequence_order = 1000 + ed.id convention from mig 085 (70 of 77
  Pipeline-C deadlines carry codes; 7 are codeless).
- Hard assertion ties source-junction-row count to backfilled
  text[]-element count — any sequence_order mismatch aborts the drop.
- Drops the mig 086 read-only trigger (orphan once event_deadlines
  goes away).
- Drops paliad.event_deadlines + paliad.event_deadline_rule_codes.
- Final assertion: >=77 active deadline_rules with trigger_event_id
  NOT NULL — Slice 3 corpus must not have collapsed.
- audit_reason wrapper at top so the deadline_rules UPDATE row-trigger
  records the reason in deadline_rule_audit.

Verified via BEGIN..ROLLBACK against the live paliad DB: 72 codes
backfilled into 70 rule_codes arrays, multi-code rules (RoP.029.a +
RoP.030 for ed_id=6) preserve their ordering, composite rules
(combine_op=max) remain intact, both tables drop cleanly, all
assertions pass.

Parity test rebound to deadline_rules — independent computation still
re-runs applyDuration against raw column values for date/composite
parity. EventDeadlineResult.ID stays int64 via the sequence_order -
1000 convention so the public /api/tools/event-deadlines wire shape
is unchanged.
2026-05-16 01:17:23 +02:00

320 lines
13 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// 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).
//
// 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 delegated and wrapped the unified
// response in the legacy CalculateResponse shape, but still SELECTed
// paliad.event_deadlines + paliad.event_deadline_rule_codes for the
// per-row metadata (DurationValue, DurationUnit, Timing, Notes, RuleCodes,
// alt_*, combine_op).
//
// Phase 3 Slice 9 follow-up A (t-paliad-199): EventDeadlineService now
// reads source rows from paliad.deadline_rules directly — the
// trigger_event_id IS NOT NULL filter scopes to the 77 Pipeline-C rows
// mig 085 unified. Multi-code citations (the legacy
// event_deadline_rule_codes junction) live in the new
// paliad.deadline_rules.rule_codes text[] column populated by mig 092's
// backfill. event_deadlines + event_deadline_rule_codes are dropped by
// mig 092; the service no longer references either.
//
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior on-service
// applyDuration / addWorkingDays helpers into package-level functions
// shared with FristenrechnerService — single source-of-truth for
// timing / working_days / holiday-rollover arithmetic.
type EventDeadlineService struct {
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, 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
// (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.
//
// Phase 3 Slice 3 (t-paliad-184) delegated 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).
//
// Phase 3 Slice 9 follow-up A (t-paliad-199): the per-row metadata
// SELECT now also reads from paliad.deadline_rules. Mig 092 dropped
// paliad.event_deadlines + paliad.event_deadline_rule_codes after
// backfilling the multi-code junction rows into
// paliad.deadline_rules.rule_codes (text[]). The legacy
// EventDeadlineResult shape is built by mapping fields:
//
// deadline_rules.name → EventDeadlineResult.TitleDE
// deadline_rules.name_en → EventDeadlineResult.Title
// deadline_rules.deadline_notes → EventDeadlineResult.Notes
// deadline_rules.deadline_notes_en → EventDeadlineResult.NotesEN
// deadline_rules.rule_codes → EventDeadlineResult.RuleCodes
// deadline_rules.sequence_order → EventDeadlineResult.ID
// (legacy event_deadlines.id semantic via mig 085's
// sequence_order = 1000 + event_deadlines.id convention)
//
// The public /api/tools/event-deadlines wire shape is unchanged from
// pre-Slice-9-followup-A — only the backing query changes.
//
// 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) {
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)
}
// Source-of-truth columns the unified UIResponse drops (the
// frontend still reads DurationValue/Unit/Timing literally to render
// the "X days after" pill). Reading from paliad.deadline_rules with
// trigger_event_id = $1 — the same row set FristenrechnerService.
// calculateByTriggerEvent uses, so a join by rule.ID is exact.
// COALESCE(timing, 'after') matches the column default. Pipeline-C
// rows seeded by mig 085 always carry an explicit timing (the
// source event_deadlines.timing was NOT NULL); the COALESCE guards
// any future hand-edited rule that left the column NULL.
var rows []eventDeadlineRuleRow
err = s.db.SelectContext(ctx, &rows, `
SELECT id, sequence_order, name, name_en, duration_value, duration_unit,
COALESCE(timing, 'after') AS timing,
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
combine_op, rule_codes
FROM paliad.deadline_rules
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY sequence_order`, triggerEventID)
if err != nil {
return nil, fmt.Errorf("load deadlines: %w", err)
}
byRuleID := make(map[uuid.UUID]eventDeadlineRuleRow, len(rows))
for _, r := range rows {
byRuleID[r.ID] = r
}
// Delegate to the unified calculator. UIResponse comes back with the
// adjusted/original dates + wasAdjusted; UIDeadline.RuleID is
// rule.ID.String(), so we can merge precisely on the rule UUID
// without relying on title_de string equality (the pre-Slice-9
// shape) — a fragile match if a rule's name ever diverges from its
// source.
unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{
TriggerEventIDFilter: &triggerEventID,
CourtID: courtID,
})
if err != nil {
return nil, err
}
// Holiday/regime resolution is cheap but happens up to N times in
// the composite-recompute loop below; pull it out so we hit the
// CourtService once per call.
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)
}
results := make([]EventDeadlineResult, 0, len(unified.Deadlines))
for _, d := range unified.Deadlines {
ruleID, perr := uuid.Parse(d.RuleID)
if perr != nil {
// UIDeadline.RuleID is always rule.ID.String() — a non-UUID
// here would mean a calculator bug. Skip defensively rather
// than fail the request.
continue
}
src, ok := byRuleID[ruleID]
if !ok {
// Defensive: a unified row exists for which no source
// deadline_rules row matches by ID. Should be impossible
// since both branches read the same rows; skip rather than
// emit a broken row.
continue
}
isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil
compositeNote := ""
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.
_, baseAdj, _, _ := applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime, s.holidays)
_, altAdj, _, _ := applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime, s.holidays)
pickedUnit := src.DurationUnit
switch *src.CombineOp {
case "max":
if altAdj.After(baseAdj) {
pickedUnit = *src.AltDurationUnit
}
case "min":
if altAdj.Before(baseAdj) {
pickedUnit = *src.AltDurationUnit
}
}
compositeNote = fmt.Sprintf("%s(%d %s, %d %s) → %s leg",
*src.CombineOp,
src.DurationValue, src.DurationUnit,
*src.AltDurationValue, *src.AltDurationUnit,
pickedUnit)
}
notes := ""
if src.DeadlineNotes != nil {
notes = *src.DeadlineNotes
}
notesEN := ""
if src.DeadlineNotesEn != nil {
notesEN = *src.DeadlineNotesEn
}
// rule_codes is NULL when the Pipeline-C rule had no junction
// rows pre-mig-092 (7 of 77 deadlines). Emit an empty slice in
// that case so the JSON contract stays `"ruleCodes": []` rather
// than `null`.
ruleCodes := []string(src.RuleCodes)
if ruleCodes == nil {
ruleCodes = []string{}
}
results = append(results, EventDeadlineResult{
// Legacy event_deadlines.id semantic: mig 085 set
// sequence_order = 1000 + event_deadlines.id, so the
// pre-Slice-9-followup-A integer IDs (1..206) round-trip
// via sequence_order - 1000. Preserves the wire contract
// for the existing 77 Pipeline-C rows; Pipeline-C rules
// added by the rule editor get whatever sequence_order
// the editor assigns (no event_deadlines counterpart).
ID: int64(src.SequenceOrder - 1000),
Title: src.NameEN,
TitleDE: src.Name,
DurationValue: src.DurationValue,
DurationUnit: src.DurationUnit,
Timing: src.Timing,
Notes: notes,
NotesEN: notesEN,
RuleCodes: ruleCodes,
DueDate: d.DueDate,
OriginalDueDate: d.OriginalDate,
WasAdjusted: d.WasAdjusted,
IsComposite: isComposite,
CompositeNote: compositeNote,
})
}
return &CalculateResponse{
TriggerEvent: trig,
TriggerDate: triggerDateStr,
Deadlines: results,
}, nil
}
// eventDeadlineRuleRow is the package-private row shape used by
// Calculate's SELECT against paliad.deadline_rules. Keeps optional
// fields as pointers (nil = no composite alt-leg / no notes). rule_codes
// is pq.StringArray so the text[] column scans cleanly; Pipeline-C
// rules without junction rows have a NULL column and end up with a nil
// slice (treated as "no codes").
type eventDeadlineRuleRow struct {
ID uuid.UUID `db:"id"`
SequenceOrder int `db:"sequence_order"`
Name string `db:"name"`
NameEN string `db:"name_en"`
DurationValue int `db:"duration_value"`
DurationUnit string `db:"duration_unit"`
Timing string `db:"timing"`
DeadlineNotes *string `db:"deadline_notes"`
DeadlineNotesEn *string `db:"deadline_notes_en"`
AltDurationValue *int `db:"alt_duration_value"`
AltDurationUnit *string `db:"alt_duration_unit"`
CombineOp *string `db:"combine_op"`
RuleCodes pq.StringArray `db:"rule_codes"`
}