Implement document reconciliation v2

This commit is contained in:
plx
2026-08-05 23:55:33 +00:00
parent 4a60607503
commit 29382ec6c1
25 changed files with 2082 additions and 343 deletions

View 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.';