#!/usr/bin/env python3 """Read-only preflight for migration 007; it never repairs production data.""" from __future__ import annotations import json import sys from pathlib import Path from typing import Any, Dict from sqlalchemy import text ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) def run_preflight(conn: Any) -> Dict[str, Any]: column_rows = list(conn.execute(text(""" SELECT table_name, column_name, data_type, udt_name FROM information_schema.columns WHERE table_schema='public' AND table_name IN ('commercial_document_lines','commercial_documents','opportunities') """))) columns = {row[1] for row in column_rows if row[0] == "commercial_document_lines"} required = {"id", "document_id"} counts: Dict[str, int] = {} details: Dict[str, list[Dict[str, Any]]] = {} blockers = [] missing = sorted(required - columns) if missing: blockers.append("commercial_document_lines missing columns: " + ", ".join(missing)) return {"counts": counts, "blockers": blockers, "columns": sorted(columns)} types = {(row[0], row[1]): row[3] for row in column_rows} for table_name, column_name in (("commercial_document_lines", "document_id"), ("commercial_documents", "id"), ("commercial_documents", "opportunity_id"), ("opportunities", "id")): actual = types.get((table_name, column_name)) if actual != "uuid": blockers.append(f"unexpected type {table_name}.{column_name}: {actual or 'missing'} (expected uuid)") counts["null_document_id"] = int(conn.execute(text( "SELECT count(*) FROM commercial_document_lines WHERE document_id IS NULL" )).scalar() or 0) counts["orphan_document_id"] = int(conn.execute(text(""" SELECT count(*) FROM commercial_document_lines dl LEFT JOIN commercial_documents d ON d.id=dl.document_id WHERE dl.document_id IS NOT NULL AND d.id IS NULL """)).scalar() or 0) duplicate_documents = [dict(row) for row in conn.execute(text(""" SELECT system, external_id, document_kind, company, array_agg(id::text ORDER BY id) AS ids, 'duplicate_external_document' AS reason FROM commercial_documents WHERE external_id IS NOT NULL AND btrim(external_id) <> '' GROUP BY system, external_id, document_kind, company HAVING count(*)>1 """)).mappings().all()] duplicate_associations = [dict(row) for row in conn.execute(text(""" SELECT opportunity_id::text, system, external_id, document_kind, array_agg(id::text ORDER BY id) AS ids, 'duplicate_opportunity_document_association' AS reason FROM commercial_documents WHERE opportunity_id IS NOT NULL AND external_id IS NOT NULL AND btrim(external_id) <> '' GROUP BY opportunity_id, system, external_id, document_kind HAVING count(*)>1 """)).mappings().all()] cross_opportunity = [dict(row) for row in conn.execute(text(""" SELECT system, external_id, document_kind, company, array_agg(id::text ORDER BY id) AS ids, array_agg(DISTINCT opportunity_id::text) AS opportunity_ids, 'external_document_linked_to_multiple_opportunities' AS reason FROM commercial_documents WHERE opportunity_id IS NOT NULL AND external_id IS NOT NULL AND btrim(external_id) <> '' GROUP BY system, external_id, document_kind, company HAVING count(DISTINCT opportunity_id)>1 """)).mappings().all()] multiple_primaries = [dict(row) for row in conn.execute(text(""" SELECT opportunity_id::text, document_kind, array_agg(id::text ORDER BY id) AS ids, 'multiple_legacy_primaries' AS reason FROM commercial_documents WHERE opportunity_id IS NOT NULL AND COALESCE(is_primary,FALSE) AND COALESCE(role,'current') IN ('current','accepted') GROUP BY opportunity_id, document_kind HAVING count(*)>1 """)).mappings().all()] details.update(duplicate_documents=duplicate_documents, duplicate_associations=duplicate_associations, cross_opportunity_documents=cross_opportunity, multiple_legacy_primaries=multiple_primaries) counts["duplicate_document_groups"] = len(duplicate_documents) counts["duplicate_associations"] = len(duplicate_associations) counts["cross_opportunity_documents"] = len(cross_opportunity) counts["multiple_legacy_primaries"] = len(multiple_primaries) for key in ("null_document_id", "orphan_document_id", "duplicate_document_groups", "duplicate_associations", "cross_opportunity_documents"): if counts[key]: blockers.append(f"{key}: {counts[key]}") if counts["multiple_legacy_primaries"]: blockers.append(f"multiple_legacy_primaries: {counts['multiple_legacy_primaries']}") return {"counts": counts, "blockers": blockers, "details": details, "columns": sorted(columns)} def main() -> int: from app.db import engine with engine.begin() as conn: result = run_preflight(conn) print(json.dumps(result, ensure_ascii=False, indent=2)) return 2 if result["blockers"] else 0 if __name__ == "__main__": raise SystemExit(main())