#!/usr/bin/env python3 """Read-only compatibility/cutover audit for BLIF Flow v2.""" from __future__ import annotations import json import sys from collections import Counter from datetime import datetime, timezone from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from sqlalchemy import text from app.db import engine from app.opportunity_next_action_service import ( _load_v2_comparison_rows, compare_v1_v2_decisions, get_opportunity_next_actions, ) from scripts.simulate_blif_flow_v2 import collect EXPECTED_DATABASE = "clientflow_codex_test" CUTOVER = Path("/tmp/blif_flow_v2_cutover_report.json") INVENTORY = Path("/tmp/blif_flow_v2_legacy_field_inventory.json") COMPARE = Path("/tmp/blif_flow_v2_compare_report.json") FIELD_INVENTORY = [ { "field": "stage", "kind": "derived_compatibility", "factual": False, "writers": ["app/opportunity_service.py", "app/admin_ui/pages/opportunities.py", "app/reconciliation_service.py", "app/odoo_service.py", "app/external_reconciliation_sync.py"], "readers": ["app/domain/opportunity_flow/evidence.py (V1 only)", "app/workflow_guard.py", "app/admin_ui/pages/opportunities.py", "app/admin_ui/pages/orders.py", "app/admin_dashboard.py", "app/revenue_forecast_service.py"], "flow_v2_replacement": "opportunity_flow_state_v2.business_state", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, "notes": "Keep stored value initially for V1 boards, reports, forecasts and action guards; never use as factual V2 input. Consumers need code migration, not historical mass rewrite.", }, { "field": "lifecycle_state", "kind": "operational_compatibility", "factual": False, "writers": ["app/followup_service.py", "app/opportunity_service.py"], "readers": ["app/operational_eligibility.py", "app/operations_service.py", "app/admin_ui/pages/opportunities.py"], "flow_v2_replacement": "business state plus active operational obligations", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, "notes": "Awaiting/recovery/nurture presentation remains V1 compatibility. It cannot create a scheduled obligation without an active task.", }, { "field": "next_follow_up_at", "kind": "denormalized_followup_compatibility", "factual": False, "writers": ["app/followup_service.py", "app/opportunity_service.py"], "readers": ["app/operations_service.py", "app/admin_ui/pages/opportunities.py"], "flow_v2_replacement": "pending follow-up task action_code/due_at", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, "notes": "Historical timestamp is inert without a pending follow-up task; retain all 44 values for now.", }, { "field": "nurture_until", "kind": "denormalized_schedule_compatibility", "factual": False, "writers": ["app/opportunity_service.py"], "readers": ["app/admin_ui/pages/opportunities.py"], "flow_v2_replacement": "explicit pending REVIEW_NURTURE/follow-up task due_at", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, }, { "field": "follow_up_attempts", "kind": "historical_counter", "factual": False, "writers": ["app/opportunity_service.py", "app/followup_service.py"], "readers": ["app/admin_ui/pages/opportunities.py"], "flow_v2_replacement": "event/task history aggregation", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, }, { "field": "last_action_code", "kind": "last-known-action_compatibility", "factual": False, "writers": ["app/opportunity_service.py", "app/admin_ui/pages/opportunities.py", "app/reconciliation_service.py", "app/odoo_service.py"], "readers": ["app/workflow_guard.py", "app/admin_dashboard.py", "app/admin_ui/pages/opportunities.py", "app/action_prompt.py"], "flow_v2_replacement": "opportunity_flow_state_v2.business_next_action plus OperationalEligibility", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, }, { "field": "last_task_id", "kind": "historical_pointer", "factual": False, "writers": ["app/opportunity_service.py", "app/reconciliation_service.py", "app/company_opportunity_linking.py"], "readers": ["app/workflow_guard.py"], "flow_v2_replacement": "active canonical task lookup", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": True, }, { "field": "pending_primary_* / pending_follow_up_*", "kind": "runtime_read_model", "factual": False, "writers": ["none (SQL projections in app/opportunity_service.py)"], "readers": ["app/admin_ui/pages/opportunities.py"], "flow_v2_replacement": "active task lookup remains an explicit operational overlay", "compatibility_required": True, "one_time_migration_required": False, "eventually_deprecatable": False, "notes": "These virtual fields correctly derive active obligations and are not stored opportunity state.", }, ] SOURCE_OF_TRUTH = [ ["business process state", "opportunity_flow_state_v2.business_state"], ["business next action", "opportunity_flow_state_v2.business_next_action"], ["time-sensitive operational queue", "OperationalEligibility"], ["scheduled follow-up", "pending task action_code + due_at"], ["formal document state", "commercial_documents + active opportunity_document_links"], ["payment", "confirmed factual payment evidence/operation link"], ["Odoo execution", "operation_links / factual Odoo evidence"], ["messages/customer response", "messages and communications chronology"], ["legacy stage", "compatibility only"], ["tasks", "operator obligations/history; never business fact proof"], ] def _write(path: Path, value: Any) -> None: path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str), encoding="utf-8") def _identity_and_ids() -> tuple[dict[str, str], list[str], int]: with engine.connect() as conn: conn.exec_driver_sql("BEGIN READ ONLY") try: identity = conn.execute(text("SELECT current_database(),current_user,current_setting('transaction_read_only')")).one() if identity[0] != EXPECTED_DATABASE or identity[2] != "on": raise RuntimeError(f"refusing unexpected/non-read-only database: {identity!r}") ids = [str(value) for value in conn.execute(text("SELECT opportunity_id FROM opportunity_flow_state_v2 ORDER BY opportunity_id")).scalars()] historical = conn.execute(text(""" SELECT count(*) FROM opportunities o WHERE o.next_follow_up_at IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM tasks t WHERE t.opportunity_id=o.id AND t.status='pending' AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN ('CALL_CUSTOMER','CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE')) ) """)).scalar_one() finally: conn.rollback() return {"database": identity[0], "user": identity[1], "transaction_read_only": identity[2]}, ids, int(historical) def main() -> None: identity, ids, historical_timestamps = _identity_and_ids() if len(ids) != 328: raise RuntimeError(f"expected 328 persisted Flow v2 opportunities, found {len(ids)}") v1 = get_opportunity_next_actions(ids) v2 = _load_v2_comparison_rows(ids) comparisons = compare_v1_v2_decisions(v1, v2) counts = Counter("agree" if row["action_agrees"] else "different" for row in comparisons) counts["missing_projection"] = sum(not row["projection_present"] for row in comparisons) compare_report = { "generated_at": datetime.now(timezone.utc), "database": identity, "mode_semantics": "V1 returned; persisted V2 observed; no business mutation", "opportunity_count": len(comparisons), "counts": dict(counts), "comparisons": comparisons, } _write(COMPARE, compare_report) inventory = { "generated_at": datetime.now(timezone.utc), "database": identity, "fields": FIELD_INVENTORY, "summary": { "fields_requiring_data_migration": [], "compatibility_only_fields": [row["field"] for row in FIELD_INVENTORY if row["compatibility_required"]], "safe_to_deprecate_after_consumer_migration": [row["field"] for row in FIELD_INVENTORY if row["eventually_deprecatable"]], "historical_followup_timestamps_preserved": historical_timestamps, }, } _write(INVENTORY, inventory) projection = collect(expected_database=EXPECTED_DATABASE, expected_user=identity["user"], require_read_only=False) named = {} for name in ("INSTALBEIRA", "PANORAMIC SUCCESS", "ENGEXICON", "CONSTRURECUP", "X MAT", "RZSOLAR"): matches = [row for row in projection["opportunities"] if name.casefold() in f"{row.get('title')} {row.get('customer')}".casefold()] named[name] = [{"opportunity_id": row["opportunity_id"], "material_process_key": row["material_process_key"], "canonical_process_id": row["canonical_process_id"], "business_state": row["safe_v2"]["business_state"], "effective_action": row["safe_v2"]["effective_operational_action"], "queue": row["safe_v2"]["effective_operational_queue"], "duplicate_suppressed": row["safe_v2"]["precedence"] == "duplicate_representation"} for row in matches] cutover = { "generated_at": datetime.now(timezone.utc), "database": identity, "source_of_truth": [{"concern": concern, "source": source} for concern, source in SOURCE_OF_TRUTH], "data_migration": {"opportunities_requiring_mutation_before_shadow": 0, "opportunities_requiring_no_mutation_before_shadow": len(ids), "broad_repair_required": False, "stage_write_migration_required": False, "lifecycle_write_migration_required": False, "followup_timestamp_cleanup_required": False}, "mode_contract": { "off": "No Flow v2 derivation or projection writes are triggered by runtime reads.", "shadow": "V1 returned; explicit projection rebuild is additive/idempotent; no tasks, stage, or UI behavior changed.", "compare": "V1 returned; V2 projection read and structured comparison logged; no disagreement writes.", "authoritative": "Disabled and fail-closed. No activation performed.", }, "production_shadow_blockers": [ "Production migration 011 presence was not and must not be checked from this environment.", "Deployment must provide an explicit projection rebuild cadence and comparison-log monitoring/retention.", ], "mode_off_deployment_blockers": [], "shadow_compare_activation_prerequisites": [ "Install migration 011 through the normal controlled production migration process.", "Run mode=off first, then explicitly rebuild projection in shadow.", "Monitor structured disagreement rates and missing projections before compare enablement.", ], "authoritative_activation_blockers": [ "Authoritative switch intentionally raises and has no enabled code path.", "Operations/UI must consume V2 business state/action followed by OperationalEligibility.", "Legacy stage consumers in orders, forecasts, dashboard, workflow guards and opportunity columns require migration or explicit compatibility adapters.", "Explicit operational override precedence must be implemented for CALL_CUSTOMER, due follow-up, SUPPORT, SEND_INFO, manual review and blockers.", "Production shadow/compare observation, rollback criteria and zero-false-negative acceptance must be completed.", ], "legacy_findings_are": "field-level compatibility findings, not required row mutations", "historical_followup_timestamps_preserved": historical_timestamps, "compare_summary": compare_report["counts"], "named_cases": named, } _write(CUTOVER, cutover) print(json.dumps({"database": identity, "opportunities": len(ids), "compare": compare_report["counts"], "historical_timestamps": historical_timestamps, "mutation_required": 0, "outputs": [str(CUTOVER), str(INVENTORY), str(COMPARE)]}, indent=2)) if __name__ == "__main__": main()