Implement document reconciliation v2
This commit is contained in:
100
migrations/007_document_reconciliation_v2.sql
Normal file
100
migrations/007_document_reconciliation_v2.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Document Reconciliation v2 (additive; no data backfill is run here).
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opportunity_document_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE RESTRICT,
|
||||
document_id UUID NOT NULL REFERENCES commercial_documents(id) ON DELETE RESTRICT,
|
||||
document_kind TEXT NOT NULL,
|
||||
relationship TEXT NOT NULL CHECK (relationship IN ('PRIMARY','SECONDARY','HISTORICAL','IGNORED','REMOVED','REASSIGNED','REVIEW_REQUIRED')),
|
||||
is_manual BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
decision_reason TEXT,
|
||||
decided_by TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
source TEXT NOT NULL,
|
||||
origin_opportunity_id UUID REFERENCES opportunities(id) ON DELETE RESTRICT,
|
||||
destination_opportunity_id UUID REFERENCES opportunities(id) ON DELETE RESTRICT,
|
||||
correlation_id TEXT,
|
||||
request_id TEXT,
|
||||
restored_from_link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
CONSTRAINT ck_document_link_reassigned CHECK (
|
||||
relationship <> 'REASSIGNED' OR (
|
||||
origin_opportunity_id IS NOT NULL AND destination_opportunity_id IS NOT NULL
|
||||
AND origin_opportunity_id <> destination_opportunity_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_current
|
||||
ON opportunity_document_links(opportunity_id, document_id) WHERE ended_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_primary_kind
|
||||
ON opportunity_document_links(opportunity_id, document_kind) WHERE ended_at IS NULL AND relationship = 'PRIMARY';
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_opportunity ON opportunity_document_links(opportunity_id, ended_at, relationship);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_document ON opportunity_document_links(document_id, ended_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_relationship ON opportunity_document_links(relationship, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_destination ON opportunity_document_links(destination_opportunity_id) WHERE destination_opportunity_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opportunity_document_link_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL,
|
||||
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE RESTRICT,
|
||||
document_id UUID NOT NULL REFERENCES commercial_documents(id) ON DELETE RESTRICT,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
old_relationship TEXT,
|
||||
new_relationship TEXT,
|
||||
old_opportunity_id UUID,
|
||||
new_opportunity_id UUID,
|
||||
old_jasmin_status TEXT,
|
||||
new_jasmin_status TEXT,
|
||||
correlation_id TEXT,
|
||||
request_id TEXT,
|
||||
idempotency_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_events_idempotency
|
||||
ON opportunity_document_link_events(idempotency_key) WHERE idempotency_key IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_document_link_events_link ON opportunity_document_link_events(link_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_link_events_opportunity ON opportunity_document_link_events(opportunity_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_link_events_document ON opportunity_document_link_events(document_id, created_at DESC);
|
||||
|
||||
-- Command claims make the mutation itself idempotent, not merely its event.
|
||||
CREATE TABLE IF NOT EXISTS document_reconciliation_commands (
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
command_payload JSONB NOT NULL,
|
||||
result_payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Events are an audit ledger: UPDATE and DELETE are rejected at database level.
|
||||
CREATE OR REPLACE FUNCTION reject_document_link_event_mutation() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'opportunity_document_link_events is append-only'; END $$;
|
||||
DROP TRIGGER IF EXISTS trg_document_link_events_append_only ON opportunity_document_link_events;
|
||||
CREATE TRIGGER trg_document_link_events_append_only BEFORE UPDATE OR DELETE ON opportunity_document_link_events
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_document_link_event_mutation();
|
||||
|
||||
ALTER TABLE commercial_document_lines ADD COLUMN IF NOT EXISTS commercial_document_id UUID REFERENCES commercial_documents(id) ON DELETE RESTRICT;
|
||||
UPDATE commercial_document_lines SET commercial_document_id = document_id WHERE commercial_document_id IS NULL;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='ck_commercial_document_lines_document_present') THEN
|
||||
ALTER TABLE commercial_document_lines ADD CONSTRAINT ck_commercial_document_lines_document_present
|
||||
CHECK (commercial_document_id IS NOT NULL) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE commercial_document_lines VALIDATE CONSTRAINT ck_commercial_document_lines_document_present;
|
||||
ALTER TABLE commercial_document_lines ALTER COLUMN commercial_document_id SET NOT NULL;
|
||||
ALTER TABLE commercial_document_lines ADD COLUMN IF NOT EXISTS opportunity_document_link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_document_lines_commercial_document ON commercial_document_lines(commercial_document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_lines_opportunity_link ON commercial_document_lines(opportunity_document_link_id);
|
||||
|
||||
COMMENT ON TABLE opportunity_document_links IS 'Canonical ClientFlow document/opportunity relationship; legacy commercial_documents fields are not authoritative.';
|
||||
COMMENT ON TABLE opportunity_document_link_events IS 'Append-only reconciliation audit ledger.';
|
||||
37
migrations/007_document_reconciliation_v2_down.sql
Normal file
37
migrations/007_document_reconciliation_v2_down.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- EXECUTE ONLY AFTER reverting application code and stopping every v2 consumer.
|
||||
-- Before running, export opportunity_document_link_events and set these session flags:
|
||||
-- SET clientflow.v2_consumers_active = 'off';
|
||||
-- SET clientflow.document_ledger_exported = 'on';
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_setting('clientflow.v2_consumers_active', true) IS DISTINCT FROM 'off' THEN
|
||||
RAISE EXCEPTION 'rollback refused: confirm all v2 consumers are stopped';
|
||||
END IF;
|
||||
IF current_setting('clientflow.document_ledger_exported', true) IS DISTINCT FROM 'on' THEN
|
||||
RAISE EXCEPTION 'rollback refused: export the reconciliation ledger first';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM commercial_documents
|
||||
WHERE opportunity_id IS NULL OR role IS NULL OR is_primary IS NULL OR is_active IS NULL) THEN
|
||||
RAISE EXCEPTION 'rollback refused: legacy compatibility fields are incomplete';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Remove dependent line foreign keys/columns before their target tables.
|
||||
ALTER TABLE commercial_document_lines DROP CONSTRAINT IF EXISTS commercial_document_lines_opportunity_document_link_id_fkey;
|
||||
DROP INDEX IF EXISTS idx_document_lines_opportunity_link;
|
||||
ALTER TABLE commercial_document_lines DROP COLUMN IF EXISTS opportunity_document_link_id;
|
||||
ALTER TABLE commercial_document_lines DROP CONSTRAINT IF EXISTS ck_commercial_document_lines_document_present;
|
||||
ALTER TABLE commercial_document_lines DROP CONSTRAINT IF EXISTS commercial_document_lines_commercial_document_id_fkey;
|
||||
DROP INDEX IF EXISTS idx_document_lines_commercial_document;
|
||||
ALTER TABLE commercial_document_lines DROP COLUMN IF EXISTS commercial_document_id;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_document_link_events_append_only ON opportunity_document_link_events;
|
||||
DROP FUNCTION IF EXISTS reject_document_link_event_mutation();
|
||||
DROP TABLE IF EXISTS opportunity_document_link_events;
|
||||
DROP TABLE IF EXISTS document_reconciliation_commands;
|
||||
DROP TABLE IF EXISTS opportunity_document_links;
|
||||
DELETE FROM schema_migrations WHERE version='007';
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user