#!/usr/bin/env python3 """Repair/suggest cross-conversation B2B opportunity links. Handles the pattern where compras/geral creates an opportunity and later financeiro/contabilidade sends payment in a new Chatwoot conversation. Read-only by default. With --apply, only strong document-reference matches are moved automatically. Same-domain matches with more than one candidate are marked for operator association. """ from __future__ import annotations import argparse import json from typing import Any, Dict, List from sqlalchemy import text from app.db import engine from app.opportunity_service import get_task_context from app.company_opportunity_linking import ( associate_task_to_opportunity, find_document_owner_opportunity_for_task, manual_link_candidates_for_task, mark_task_ambiguous_with_candidates, task_document_references, ) def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def task_ids(days: int, limit: int) -> List[str]: with engine.begin() as conn: rows = conn.execute(text(""" SELECT t.id::text FROM tasks t LEFT JOIN raw_events re ON re.id = t.raw_event_id LEFT JOIN messages m ON m.id = t.message_id WHERE t.created_at >= now() - make_interval(days => :days) AND COALESCE(t.status, '') IN ('pending','done','skipped') AND COALESCE(t.action_code, '') IN ('CONFIRM_PAYMENT','SEND_PROFORMA','SEND_INVOICE','SEND_QUOTE','PREPARE_ORDER','CREATE_SHIPMENT') AND ( COALESCE(re.payload::text, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}' OR COALESCE(m.raw_body, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}' OR COALESCE(m.clean_body, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}' OR COALESCE(t.note, '') ~* '(ORC|Orçamento|orcamento|FA|Fatura|Factura)[ .:/-]*20[0-9]{2}[ ./-]*[0-9]{1,6}' ) ORDER BY t.created_at DESC LIMIT :limit """), {"days": int(days), "limit": int(limit)}).scalars().all() return [str(x) for x in rows] def source_opportunity_document_count(opportunity_id: str) -> int: if not opportunity_id: return 0 with engine.begin() as conn: return int(conn.execute(text(""" SELECT COUNT(*)::int FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND COALESCE(is_active, TRUE) = TRUE """), {"opportunity_id": opportunity_id}).scalar() or 0) def close_empty_duplicate(source_opportunity_id: str, target_opportunity_id: str, refs: List[str], apply: bool) -> int: if not source_opportunity_id or source_opportunity_id == target_opportunity_id: return 0 if source_opportunity_document_count(source_opportunity_id) > 0: return 0 if not apply: return 0 meta = { "closed_as_duplicate_by": "repair_cross_conversation_company_links", "duplicate_of_opportunity_id": target_opportunity_id, "document_references": refs, } with engine.begin() as conn: res = conn.execute(text(""" UPDATE opportunities SET status = 'closed', closed_at = COALESCE(closed_at, now()), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:source AS UUID) AND status = 'open' """), {"source": source_opportunity_id, "metadata": _json(meta)}) return int(res.rowcount or 0) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--days", type=int, default=14) parser.add_argument("--limit", type=int, default=200) parser.add_argument("--apply", action="store_true") args = parser.parse_args() ids = task_ids(args.days, args.limit) print(f"apply={args.apply} scanned={len(ids)} days={args.days}") strong = 0 moved = 0 closed = 0 ambiguous = 0 for task_id in ids: task = get_task_context(task_id) if not task: continue refs = task_document_references(task) target = find_document_owner_opportunity_for_task(task) source_id = str(task.get("opportunity_id") or "") if target: target_id = str(target.get("id") or target.get("opportunity_id") or "") if target_id and target_id != source_id: strong += 1 print() print(f"STRONG_DOCUMENT_MATCH task={task_id}") print(f"refs={refs}") print(f"source_opp={source_id or '—'}") print(f"target_opp={target_id} title={target.get('title')} doc={target.get('document_number')}") if args.apply: result = associate_task_to_opportunity(task_id, target_id, actor="repair_cross_conversation_company_links") moved += result.get("tasks", 0) closed += close_empty_duplicate(source_id, target_id, refs, apply=True) continue candidates = manual_link_candidates_for_task(task) if len(candidates) > 1: ambiguous += 1 print() print(f"AMBIGUOUS_COMPANY_MATCH task={task_id} candidates={len(candidates)} refs={refs}") for c in candidates[:5]: print(f"- opp={c.get('opportunity_id')} title={c.get('title')} doc={c.get('document_number')} reason={c.get('reason')}") if args.apply: mark_task_ambiguous_with_candidates( task_id, reason="multiple_company_domain_candidates", candidates=candidates, created_by="repair_cross_conversation_company_links", ) print() print("SUMMARY") print(f"strong_document_matches={strong}") print(f"tasks_moved={moved}") print(f"duplicates_closed={closed}") print(f"ambiguous_marked_or_listed={ambiguous}") if not args.apply: print("dry_run=True") return 0 if __name__ == "__main__": raise SystemExit(main())