Merge: t-paliad-199 — Slice 9 follow-up A (drop legacy event_deadlines tables, EventDeadlineService refactored onto deadline_rules)

This commit is contained in:
mAi
2026-05-16 01:18:21 +02:00
4 changed files with 479 additions and 131 deletions

View File

@@ -0,0 +1,116 @@
-- t-paliad-199 down — reverses 092_drop_event_deadlines_tables.up.sql.
--
-- Re-creates paliad.event_deadlines + paliad.event_deadline_rule_codes
-- with the schema they had at end of mig 086 (the read-only state right
-- before mig 092 dropped them), repopulates from the _pre_092
-- snapshots, restores the mig 086 read-only trigger, and drops the
-- rule_codes column the up migration added to paliad.deadline_rules.
--
-- The snapshot tables themselves stay — they're the source of this
-- rollback's data and a permanent audit artefact. A focused
-- follow-up slice / Slice 12 cleanup drops the snapshots once
-- Slice 9 is verified in prod.
SELECT set_config(
'paliad.audit_reason',
'rollback 092: restore paliad.event_deadlines + event_deadline_rule_codes from pre-drop snapshots and drop rule_codes column',
true);
-- =============================================================================
-- 1. Recreate paliad.event_deadlines. Schema matches the live state at
-- the start of mig 092 (post-mig-086, with the notes_en column from
-- mig 036 and the legal_source column from mig 038).
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadlines (
id bigint PRIMARY KEY,
trigger_event_id bigint NOT NULL REFERENCES paliad.trigger_events(id) ON DELETE CASCADE,
title text NOT NULL,
title_de text NOT NULL DEFAULT '',
duration_value integer NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'days'
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days')),
timing text NOT NULL DEFAULT 'after'
CHECK (timing IN ('before', 'after')),
notes text NOT NULL DEFAULT '',
alt_duration_value integer,
alt_duration_unit text CHECK (alt_duration_unit IS NULL OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days')),
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max', 'min')),
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
notes_en text,
legal_source text
);
CREATE INDEX IF NOT EXISTS event_deadlines_trigger_event_idx
ON paliad.event_deadlines (trigger_event_id);
CREATE INDEX IF NOT EXISTS event_deadlines_active_idx
ON paliad.event_deadlines (is_active) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS event_deadlines_legal_src_trgm
ON paliad.event_deadlines USING gin (legal_source gin_trgm_ops);
INSERT INTO paliad.event_deadlines
(id, trigger_event_id, title, title_de, duration_value, duration_unit,
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
is_active, created_at, updated_at, notes_en, legal_source)
SELECT id, trigger_event_id, title, title_de, duration_value, duration_unit,
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
is_active, created_at, updated_at, notes_en, legal_source
FROM paliad.event_deadlines_pre_092
ON CONFLICT (id) DO NOTHING;
-- =============================================================================
-- 2. Recreate paliad.event_deadline_rule_codes.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes (
event_deadline_id bigint NOT NULL REFERENCES paliad.event_deadlines(id) ON DELETE CASCADE,
rule_code text NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
PRIMARY KEY (event_deadline_id, rule_code)
);
CREATE INDEX IF NOT EXISTS event_deadline_rule_codes_code_idx
ON paliad.event_deadline_rule_codes (rule_code);
INSERT INTO paliad.event_deadline_rule_codes
(event_deadline_id, rule_code, sort_order)
SELECT event_deadline_id, rule_code, sort_order
FROM paliad.event_deadline_rule_codes_pre_092
ON CONFLICT (event_deadline_id, rule_code) DO NOTHING;
-- =============================================================================
-- 3. Restore the mig 086 read-only trigger + function (the rolled-back
-- state IS "Slice 3 + Slice 9 only", which had the trigger in place).
-- =============================================================================
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
'writes must go through paliad.deadline_rules (Pipeline C is '
'unified; the source table is preserved as an audit anchor '
'until Slice 9 drops it). Operation: %', TG_OP;
END;
$$;
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
CREATE TRIGGER event_deadlines_readonly
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
FOR EACH ROW
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
-- =============================================================================
-- 4. Drop the rule_codes column the up migration added. The data is
-- preserved in paliad.event_deadline_rule_codes (just restored
-- above), so dropping the column doesn't lose history.
-- =============================================================================
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS rule_codes;

View File

@@ -0,0 +1,195 @@
-- t-paliad-199 / Fristen Phase 3 Slice 9 follow-up A — drop the legacy
-- Pipeline-C source tables (paliad.event_deadlines +
-- paliad.event_deadline_rule_codes) and the read-only trigger from
-- mig 086, now that EventDeadlineService.Calculate has been rewritten
-- to read from paliad.deadline_rules.
--
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because the
-- legacy service still SELECTed event_deadlines.duration_value /
-- duration_unit / timing / notes / alt_* / combine_op. Slice 9
-- follow-up A refactors the service onto deadline_rules (the unified
-- source-of-truth since Slice 3 / mig 085) and frees us to remove the
-- old tables.
--
-- Sequencing — every step in this single migration is required for the
-- drop to be safe:
--
-- 1. Snapshot both source tables into paliad.event_deadlines_pre_092
-- + paliad.event_deadline_rule_codes_pre_092 (CREATE TABLE IF NOT
-- EXISTS — idempotent re-run). The snapshots persist after the
-- drop as audit anchors; the down migration restores from them.
-- 2. ADD COLUMN rule_codes text[] to paliad.deadline_rules and
-- backfill from paliad.event_deadline_rule_codes. Pipeline-C
-- deadlines carry multi-code rules (e.g. R.198 / R.213 carry
-- [RoP.029.a, RoP.030]) which don't fit deadline_rules.rule_code
-- (singular text); mig 085 left rule_code NULL on the 77
-- Pipeline-C rows. Without this backfill the drop would silently
-- lose 72 RoP citations.
-- 3. Hard assertion: every event_deadline_rule_codes row resolves to
-- a deadline_rules row via the sequence_order = 1000 +
-- event_deadlines.id convention from mig 085. If any row didn't
-- land, fail loudly before dropping the source.
-- 4. DROP TRIGGER + FUNCTION from mig 086 — orphan once the table is
-- gone.
-- 5. DROP TABLE paliad.event_deadline_rule_codes (FK side first).
-- 6. DROP TABLE paliad.event_deadlines.
-- 7. Final assertion: paliad.deadline_rules still carries >=77 active
-- rows with trigger_event_id IS NOT NULL (the Slice 3 corpus must
-- not have collapsed).
--
-- audit_reason wrapper at top — the mig 079 trigger on
-- paliad.deadline_rules logs every row-level edit. The ALTER TABLE +
-- UPDATE on rule_codes fires through that trigger, so the reason
-- persists in paliad.deadline_rule_audit for forever-grade audit.
SELECT set_config(
'paliad.audit_reason',
'mig 092: drop paliad.event_deadlines + event_deadline_rule_codes after backfilling rule_codes into deadline_rules (t-paliad-199, Slice 9 follow-up A, design §3.E)',
true);
-- =============================================================================
-- 1. Backup snapshots — full row copies so the down migration can
-- rebuild both tables byte-identically. CREATE TABLE IF NOT EXISTS
-- keeps the migration idempotent across reapplications; if the
-- snapshot already exists from a prior aborted run, we re-use it.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.event_deadlines_pre_092 AS
SELECT *, now() AS snapshotted_at
FROM paliad.event_deadlines;
COMMENT ON TABLE paliad.event_deadlines_pre_092 IS
'Snapshot of paliad.event_deadlines before mig 092 dropped it. '
'Source-of-truth for the down migration; persists post-drop as the '
'permanent audit record of the 77 Pipeline-C source rows that '
'seeded paliad.deadline_rules via mig 085. Drop with a focused '
'follow-up after Slice 9 is verified in prod (pair with '
'paliad.deadline_rules_pre_091 cleanup).';
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes_pre_092 AS
SELECT *, now() AS snapshotted_at
FROM paliad.event_deadline_rule_codes;
COMMENT ON TABLE paliad.event_deadline_rule_codes_pre_092 IS
'Snapshot of paliad.event_deadline_rule_codes before mig 092 dropped '
'it. Restored by the down migration; persists post-drop as the '
'permanent audit record of the legacy RoP citations attached to '
'Pipeline-C deadlines (72 rows across 70 of 77 deadlines).';
-- =============================================================================
-- 2. Add paliad.deadline_rules.rule_codes (text[]) and backfill it for
-- the 77 Pipeline-C rules. Mig 085 set rule_code = NULL on every
-- Pipeline-C row because deadline_rules.rule_code is singular and
-- Pipeline-C deadlines can carry multiple citations. rule_codes
-- holds the array form. Pipeline-A rules keep NULL here and continue
-- using rule_code; this column is a Pipeline-C-only field today.
-- =============================================================================
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS rule_codes text[];
COMMENT ON COLUMN paliad.deadline_rules.rule_codes IS
'Array of legal-rule citations attached to this deadline, in '
'render order. Pipeline-C rules (event-rooted, trigger_event_id IS '
'NOT NULL) populate this column from the legacy '
'paliad.event_deadline_rule_codes junction (mig 092 backfill); '
'Pipeline-A rules use the singular rule_code column instead. NULL '
'on Pipeline-A rules + on the 7 Pipeline-C deadlines that had no '
'junction rows pre-mig.';
-- Aggregate junction rows into a text[] sorted by (sort_order,
-- rule_code) — matches the legacy ORDER BY contract that
-- EventDeadlineService.loadRuleCodes used.
--
-- Join key: the sequence_order = 1000 + event_deadlines.id convention
-- mig 085 anchored. Every active event_deadlines.id has a corresponding
-- deadline_rules row at sequence_order = 1000 + id; mig 085's hard
-- assertion guarantees that.
WITH agg AS (
SELECT event_deadline_id,
array_agg(rule_code ORDER BY sort_order, rule_code) AS codes
FROM paliad.event_deadline_rule_codes
GROUP BY event_deadline_id
)
UPDATE paliad.deadline_rules dr
SET rule_codes = agg.codes
FROM agg
WHERE dr.trigger_event_id IS NOT NULL
AND dr.sequence_order = 1000 + agg.event_deadline_id
AND dr.rule_codes IS DISTINCT FROM agg.codes;
-- =============================================================================
-- 3. Hard assertion: every junction row landed on a deadline_rules row.
-- Sums elements across all rule_codes arrays — if the count differs
-- from the source junction count, some event_deadline_id failed to
-- match any deadline_rules row (sequence_order convention broken).
-- Fail loudly here BEFORE dropping the source.
-- =============================================================================
DO $$
DECLARE
n_codes_src int;
n_codes_target int;
BEGIN
SELECT count(*) INTO n_codes_src
FROM paliad.event_deadline_rule_codes;
SELECT COALESCE(SUM(array_length(rule_codes, 1)), 0) INTO n_codes_target
FROM paliad.deadline_rules
WHERE rule_codes IS NOT NULL;
RAISE NOTICE 'mig 092: junction rows=%, backfilled rule_codes elements=%',
n_codes_src, n_codes_target;
IF n_codes_target < n_codes_src THEN
RAISE EXCEPTION 'mig 092: rule_codes backfill missed % junction rows '
'(source=%, target=%) — sequence_order = 1000 + ed.id '
'convention broken? Aborting before drop.',
n_codes_src - n_codes_target, n_codes_src, n_codes_target;
END IF;
END $$;
-- =============================================================================
-- 4. Drop the read-only trigger + function from mig 086. They're orphan
-- once paliad.event_deadlines goes away — explicit drop documents
-- that the wrapper's job is done, and keeps the symmetric reverse in
-- the down migration cleanly readable.
-- =============================================================================
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
-- =============================================================================
-- 5. Drop the legacy tables. Order: junction first (it has a FK to
-- event_deadlines), then the parent. Explicit ordering is clearer
-- than relying on CASCADE and mirrors the down migration's CREATE
-- sequence.
-- =============================================================================
DROP TABLE IF EXISTS paliad.event_deadline_rule_codes;
DROP TABLE IF EXISTS paliad.event_deadlines;
-- =============================================================================
-- 6. Final assertion: the unified Pipeline-C corpus is still intact.
-- Mig 085 moved 77 active rows; future hand-edited Pipeline-C rules
-- can only raise the count. A drop below 77 means the upstream
-- deadline_rules data was clobbered while this migration ran and
-- the deploy must abort.
-- =============================================================================
DO $$
DECLARE
n_unified int;
BEGIN
SELECT count(*) INTO n_unified
FROM paliad.deadline_rules
WHERE trigger_event_id IS NOT NULL AND is_active = true;
RAISE NOTICE 'mig 092: post-drop Pipeline-C rule count = %', n_unified;
IF n_unified < 77 THEN
RAISE EXCEPTION 'mig 092: Pipeline-C corpus collapsed — expected >=77 '
'active deadline_rules with trigger_event_id IS NOT NULL, got %',
n_unified;
END IF;
END $$;

View File

@@ -7,7 +7,9 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode:
@@ -18,29 +20,34 @@ import (
// 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.
// 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
db *sqlx.DB
calc *DeadlineCalculator
holidays *HolidayService
courts *CourtService
fristenrechner *FristenrechnerService
}
// 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.
// 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,
@@ -107,20 +114,29 @@ type CalculateResponse struct {
// Calculate resolves all deadlines flowing from a trigger event + date.
//
// Phase 3 Slice 3 (t-paliad-184) delegates the rule SELECT + math to
// 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). 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.
// moved out of event_deadlines).
//
// 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.
// 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.
@@ -139,34 +155,37 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
// 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
// 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, title, title_de, duration_value, duration_unit, timing,
notes, notes_en, alt_duration_value, alt_duration_unit, combine_op
FROM paliad.event_deadlines
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 id`, triggerEventID)
ORDER BY sequence_order`, triggerEventID)
if err != nil {
return nil, fmt.Errorf("load deadlines: %w", err)
}
ids := make([]int64, 0, len(rows))
byTitleDE := make(map[string]eventDeadlineRow, len(rows))
byRuleID := make(map[uuid.UUID]eventDeadlineRuleRow, 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
byRuleID[r.ID] = r
}
// 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).
// 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,
@@ -175,15 +194,33 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
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 {
src, ok := byTitleDE[d.Name]
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
// 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.
// 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
@@ -192,14 +229,6 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
// 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, _, _ := 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
@@ -219,20 +248,39 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
*src.AltDurationValue, *src.AltDurationUnit,
pickedUnit)
}
notes := ""
if src.DeadlineNotes != nil {
notes = *src.DeadlineNotes
}
notesEN := ""
if src.NotesEN != nil {
notesEN = *src.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{
ID: src.ID,
Title: src.Title,
TitleDE: src.TitleDE,
// 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: src.Notes,
Notes: notes,
NotesEN: notesEN,
RuleCodes: codes[src.ID],
RuleCodes: ruleCodes,
DueDate: d.DueDate,
OriginalDueDate: d.OriginalDate,
WasAdjusted: d.WasAdjusted,
@@ -248,49 +296,24 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
}, nil
}
// 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
// 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"`
}

View File

@@ -140,18 +140,25 @@ func TestComposite_R198_LongerLegWins(t *testing.T) {
// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
// in paliad.event_deadlines, it calls EventDeadlineService.Calculate (now
// delegating to FristenrechnerService.calculateByTriggerEvent) AND
// independently computes the same dates via the legacy applyDuration
// helper directly against event_deadlines. Any divergence — date,
// composite-flag, rule_codes — signals a Pipeline-C regression that
// "Was kommt nach…" users would see in production.
// in the Pipeline-C corpus, it calls EventDeadlineService.Calculate (now
// fully delegating to FristenrechnerService.calculateByTriggerEvent) AND
// independently computes the same dates via the package-level
// applyDuration helper against the same deadline_rules source rows. Any
// divergence — date, composite-flag, rule_codes — signals a Pipeline-C
// regression that "Was kommt nach…" users would see in production.
//
// Why this matters: design §3.C + §3.2 cutover-ordering invariant 1 says
// "additive schema lands first" and invariant 3 says "service rewrite
// before drops". Slice 3 is the first slice where the unified backend
// becomes the live serving path for event-driven deadlines. If parity
// breaks here, every downstream slice rests on a regressed foundation.
// Phase 3 Slice 9 follow-up A (t-paliad-199): mig 092 dropped
// paliad.event_deadlines + paliad.event_deadline_rule_codes. The test
// source query now reads from paliad.deadline_rules WHERE
// trigger_event_id IS NOT NULL — the unified row set the service
// reads. The independent computation is still meaningful: it bypasses
// FristenrechnerService entirely and re-runs the package-level
// applyDuration math against the raw column values, so any future
// regression in the calculator's wrapping logic surfaces here.
//
// Field mapping (post-mig-092): name_en → Title, name → TitleDE,
// (sequence_order - 1000) → ID (legacy event_deadlines.id semantic via
// mig 085's sequence_order = 1000 + ed.id convention).
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
@@ -177,18 +184,19 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
svc := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
// Distinct trigger_event_id values for which we have at least one
// active deadline in event_deadlines. The Slice 1 / Slice 2 / Slice 3
// chain doesn't touch event_deadlines, so this set is stable.
// active Pipeline-C rule. Mig 085 moved 77 active rows from
// event_deadlines into deadline_rules with trigger_event_id IS NOT
// NULL, so the set is stable across Slice 9 + follow-up A.
var triggerIDs []int64
if err := pool.SelectContext(ctx, &triggerIDs,
`SELECT DISTINCT trigger_event_id
FROM paliad.event_deadlines
WHERE is_active = true
FROM paliad.deadline_rules
WHERE trigger_event_id IS NOT NULL AND is_active = true
ORDER BY trigger_event_id`); err != nil {
t.Fatalf("list trigger ids: %v", err)
}
if len(triggerIDs) == 0 {
t.Fatal("no event_deadlines rows — pipeline C corpus missing")
t.Fatal("no Pipeline-C rules — corpus missing")
}
// Reference date — arbitrary working day so weekend rollover noise is
@@ -201,6 +209,9 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
t.Fatalf("default court regime: %v", err)
}
// Source-row shape mirrors EventDeadlineResult's columns so the
// comparison is direct. ID derives from sequence_order via the
// mig 085 convention; the post-mig-092 service does the same.
type srcRow struct {
ID int64 `db:"id"`
Title string `db:"title"`
@@ -223,11 +234,15 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
var src []srcRow
if err := pool.SelectContext(ctx, &src,
`SELECT id, title, title_de, duration_value, duration_unit, timing,
`SELECT (sequence_order - 1000) AS id,
name_en AS title,
name AS title_de,
duration_value, duration_unit,
COALESCE(timing, 'after') AS timing,
alt_duration_value, alt_duration_unit, combine_op
FROM paliad.event_deadlines
FROM paliad.deadline_rules
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY id`, tid); err != nil {
ORDER BY sequence_order`, tid); err != nil {
t.Fatalf("trigger=%d load source: %v", tid, err)
}
@@ -236,10 +251,9 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
continue
}
// Sort both by ID — Calculate's source SELECT also ORDER BY id, so
// after we look up the source row for each result we can compare
// positionally. (The unified path returns rows in sequence_order =
// 1000 + ed.id which is identical ordering.)
// Sort both by ID — the source SELECT ORDER BYs sequence_order
// and we derive ID = sequence_order - 1000, so positional
// comparison after the sort is exact.
sort.Slice(resp.Deadlines, func(i, j int) bool {
return resp.Deadlines[i].ID < resp.Deadlines[j].ID
})