Merge: t-paliad-199 — Slice 9 follow-up A (drop legacy event_deadlines tables, EventDeadlineService refactored onto deadline_rules)
This commit is contained in:
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal file
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal 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;
|
||||
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal file
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal 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 $$;
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user