#!/usr/bin/env python3 """Repair safe opportunity-detail inconsistencies. Fixes the class of issues seen after Chatwoot recovery: - payment is confirmed but old pending payment/follow-up tasks still drive the UI; - fiscal data can be safely completed from the linked Jasmin document. By default this is a dry-run. Use --apply to write changes. """ from __future__ import annotations import argparse from typing import Any from sqlalchemy import text from app.db import engine from app.task_service import mark_obsolete_payment_followup_tasks from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync, get_jasmin_fiscal_sync_preview def _rows(sql: str, params: dict[str, Any]) -> list[dict[str, Any]]: with engine.begin() as conn: return [dict(r) for r in conn.execute(text(sql), params).mappings().all()] def _scalar(sql: str, params: dict[str, Any]) -> Any: with engine.begin() as conn: return conn.execute(text(sql), params).scalar() def _select_opportunities(args: argparse.Namespace) -> list[dict[str, Any]]: if args.opportunity_id: return _rows(""" SELECT id::text, title, conversation_id FROM opportunities WHERE id = CAST(:id AS UUID) """, {"id": args.opportunity_id}) if args.conversation_id: return _rows(""" SELECT id::text, title, conversation_id FROM opportunities WHERE conversation_id = :conversation_id AND COALESCE(status, 'open') <> 'closed' ORDER BY updated_at DESC """, {"conversation_id": str(args.conversation_id)}) if args.document_number: return _rows(""" SELECT DISTINCT o.id::text, o.title, o.conversation_id FROM opportunities o JOIN commercial_documents d ON d.opportunity_id = o.id WHERE d.document_number = :document_number AND COALESCE(o.status, 'open') <> 'closed' ORDER BY o.updated_at DESC """, {"document_number": str(args.document_number)}) if args.all_open: return _rows(""" SELECT DISTINCT o.id::text, o.title, o.conversation_id FROM opportunities o LEFT JOIN operation_links p ON p.opportunity_id = o.id AND p.system = 'clientflow' AND p.external_type = 'payment' AND p.status = 'confirmed' LEFT JOIN tasks t ON t.opportunity_id = o.id AND t.status = 'pending' AND upper(COALESCE(t.action_code,'')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE') LEFT JOIN commercial_documents d ON d.opportunity_id = o.id AND d.system = 'jasmin' AND COALESCE(d.is_active, TRUE) = TRUE LEFT JOIN customers c ON c.id = o.local_customer_id WHERE COALESCE(o.status, 'open') <> 'closed' AND ( ((p.id IS NOT NULL OR o.stage = 'PAYMENT_CONFIRMED') AND t.id IS NOT NULL) OR ( d.id IS NOT NULL AND ( o.local_customer_id IS NULL OR COALESCE(c.email,'') = '' OR COALESCE(c.street_name,'') = '' OR COALESCE(c.postal_zone,'') = '' OR COALESCE(c.city_name,'') = '' ) ) ) ORDER BY o.title NULLS LAST, o.id LIMIT :limit """, {"limit": int(args.limit or 200)}) raise SystemExit("Use --opportunity-id, --conversation-id, --document-number or --all-open") def _payment_confirmed(opportunity_id: str) -> bool: return bool(_scalar(""" SELECT 1 FROM opportunities o LEFT JOIN operation_links p ON p.opportunity_id = o.id AND p.system = 'clientflow' AND p.external_type = 'payment' AND p.status = 'confirmed' WHERE o.id = CAST(:id AS UUID) AND (p.id IS NOT NULL OR o.stage = 'PAYMENT_CONFIRMED') LIMIT 1 """, {"id": opportunity_id})) def _obsolete_pending_count(opportunity_id: str) -> int: return int(_scalar(""" SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:id AS UUID) AND status = 'pending' AND upper(COALESCE(action_code,'')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE') """, {"id": opportunity_id}) or 0) def main() -> int: parser = argparse.ArgumentParser(description="Repair safe opportunity consistency issues.") parser.add_argument("--opportunity-id") parser.add_argument("--conversation-id") parser.add_argument("--document-number") parser.add_argument("--all-open", action="store_true") parser.add_argument("--limit", type=int, default=200) parser.add_argument("--apply", action="store_true", help="Write safe repairs. Without this, only reports.") parser.add_argument("--complete-fiscal-from-jasmin", action="store_true", help="Also fill empty fiscal fields from linked Jasmin documents when safe.") args = parser.parse_args() opportunities = _select_opportunities(args) print(f"opportunities={len(opportunities)} apply={args.apply} complete_fiscal_from_jasmin={args.complete_fiscal_from_jasmin}") total_closed = 0 total_fiscal = 0 for opp in opportunities: oid = str(opp.get("id")) title = opp.get("title") or oid payment_ok = _payment_confirmed(oid) obsolete_count = _obsolete_pending_count(oid) if payment_ok else 0 print("\n" + "=" * 80) print(f"{title}") print(f"id={oid} conversation={opp.get('conversation_id') or '—'}") print(f"payment_confirmed={payment_ok} obsolete_payment_tasks={obsolete_count}") if args.apply and payment_ok and obsolete_count: closed = mark_obsolete_payment_followup_tasks(oid, actor="repair_opportunity_consistency") total_closed += closed print(f"closed_obsolete_tasks={closed}") try: preview = get_jasmin_fiscal_sync_preview(oid) except Exception as exc: print(f"jasmin_fiscal_preview_error={exc}") preview = {"available": False} if preview.get("available"): print(f"jasmin_available=True conflict={bool(preview.get('conflict'))} fillable={preview.get('fillable_fields') or []} missing={preview.get('missing_fields') or []}") if args.apply and args.complete_fiscal_from_jasmin and not preview.get("conflict") and (preview.get("fillable_fields") or not preview.get("linked_customer_id")): try: result = apply_jasmin_fiscal_sync(oid, actor="repair_opportunity_consistency") total_fiscal += 1 print(f"fiscal_sync_applied=True filled={result.get('filled_fields') or []} customer_id={result.get('customer_id')}") except Exception as exc: print(f"fiscal_sync_error={exc}") else: print(f"jasmin_available=False reason={preview.get('reason') or 'unknown'}") print("\nSUMMARY") print(f"closed_obsolete_tasks={total_closed}") print(f"fiscal_sync_applied={total_fiscal}") return 0 if __name__ == "__main__": raise SystemExit(main())