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.
320 lines
13 KiB
Go
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"`
|
|
}
|