#!/usr/bin/env python3 """Audit and optionally detach unsafe fiscal-customer associations. Default mode is read-only. The audit is intentionally conservative: it only flags a task when an opportunity has a linked fiscal customer and that fiscal customer clearly does not match the process/company implied by the task title. It does NOT flag normal B2B situations where the process is a company and the Chatwoot sender is a person from that company, and it does NOT detach opportunities that already have no fiscal customer. Use: python scripts/audit_task_identity_mismatch.py python scripts/audit_task_identity_mismatch.py --apply """ from __future__ import annotations import argparse import json import re from datetime import datetime, timezone from pathlib import Path from typing import Any from dotenv import load_dotenv from sqlalchemy import text PROJECT_ROOT = Path(__file__).resolve().parents[1] load_dotenv(PROJECT_ROOT / ".env") from app.db import engine # noqa: E402 STOPWORDS = { "da", "de", "do", "dos", "das", "e", "lda", "ltda", "unipessoal", "sa", "s.a", "s.a.", "email", "mail", "geral", "info", "office", "frontoffice", "comercial", "vendas", "admin", "contacto", "contact", } def norm(value: Any) -> str: return " ".join(re.sub(r"[^0-9a-zA-ZÀ-ÿ]+", " ", str(value or "").casefold()).split()) def tokens(value: Any) -> set[str]: return {part for part in norm(value).split() if len(part) >= 3 and part not in STOPWORDS} def overlaps(left: Any, right: Any) -> bool: a = tokens(left) b = tokens(right) if not a or not b: return False if a & b: return True return any(x in y or y in x for x in a for y in b) def email_domain(value: Any) -> str: email = str(value or "").strip().casefold() if "@" not in email: return "" domain = email.rsplit("@", 1)[-1].strip() return domain if "." in domain else "" def domains_match(*values: Any) -> bool: domains = [email_domain(v) for v in values if email_domain(v)] if len(domains) < 2: return False first = domains[0] return any(d == first for d in domains[1:]) def is_unsafe_fiscal_link(row: dict[str, Any], hint: str) -> bool: # Safe-by-default: without a fiscal link there is nothing to detach. if not row.get("linked_customer_id"): return False fiscal = str(row.get("linked_customer_name") or "").strip() if not fiscal: return False # Normal B2B: fiscal company name shares strong tokens with the process # even when the sender/opportunity customer is a person. if overlaps(hint, fiscal): return False # If the fiscal email domain matches the opportunity/sender domain, treat # it as ambiguous, not automatically unsafe. fiscal_email = row.get("linked_customer_email") if domains_match(fiscal_email, row.get("opportunity_customer_email")) or domains_match(fiscal_email, row.get("sender_email")): return False return True def process_hint(title: Any) -> str: value = str(title or "").strip() if not value: return "" if "·" in value: tail = value.rsplit("·", 1)[-1].strip() if tail and not tail.upper().startswith(("ORC.", "S0")): return tail match = re.search(r"\bpara\s+(?:a|o|as|os|à|ao)?\s*(.+)$", value, re.IGNORECASE) if not match: return "" candidate = re.sub(r"\s+", " ", match.group(1)).strip(" .:-–—") candidate = re.split(r"\s+(?:de:|from:|enviada:|sent:)", candidate, maxsplit=1, flags=re.IGNORECASE)[0].strip() return candidate if 2 <= len(candidate) <= 120 else "" def find_suspects(limit: int = 500) -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute(text(""" SELECT t.id::text AS task_id, t.created_at, t.action_code, t.status, t.opportunity_id::text AS opportunity_id, COALESCE(NULLIF(t.conversation_id, ''), NULLIF(o.conversation_id, '')) AS conversation_id, COALESCE(NULLIF(o.title, ''), NULLIF(re.payload->'conversation'->'additional_attributes'->>'mail_subject', ''), NULLIF(re.payload->'content_attributes'->'email'->>'subject', '')) AS title, o.customer_name AS opportunity_customer_name, o.customer_email AS opportunity_customer_email, o.local_customer_id::text AS linked_customer_id, cu.name AS linked_customer_name, cu.tax_id AS linked_customer_tax_id, cu.email AS linked_customer_email, COALESCE(NULLIF(re.payload->'sender'->>'name', ''), NULLIF(re.payload->'conversation'->'meta'->'sender'->>'name', '')) AS sender_name, COALESCE(NULLIF(re.payload->'sender'->>'email', ''), NULLIF(re.payload->'conversation'->'meta'->'sender'->>'email', '')) AS sender_email FROM tasks t LEFT JOIN raw_events re ON re.id = t.raw_event_id LEFT JOIN opportunities o ON o.id = t.opportunity_id LEFT JOIN customers cu ON cu.id = o.local_customer_id WHERE t.status = 'pending' AND t.opportunity_id IS NOT NULL AND (cu.name IS NOT NULL OR COALESCE(o.customer_name, '') <> '') ORDER BY t.created_at DESC LIMIT :limit """), {"limit": limit}).mappings().all() suspects: list[dict[str, Any]] = [] seen: set[str] = set() for row in rows: row_dict = dict(row) hint = process_hint(row_dict.get("title")) if not hint: continue fiscal_mismatch = is_unsafe_fiscal_link(row_dict, hint) if not fiscal_mismatch: continue key = str(row_dict.get("task_id")) if key in seen: continue seen.add(key) opportunity_customer = str(row_dict.get("opportunity_customer_name") or "").strip() suspects.append({ **row_dict, "process_customer_hint": hint, "fiscal_mismatch": fiscal_mismatch, "opportunity_mismatch": bool(opportunity_customer and not overlaps(hint, opportunity_customer)), }) return suspects def print_suspects(suspects: list[dict[str, Any]]) -> None: print(f"Suspeitas de cliente fiscal inseguro: {len(suspects)}") for row in suspects[:100]: print("=" * 100) print(f"task_id: {row['task_id']}") print(f"opportunity_id: {row.get('opportunity_id') or '—'}") print(f"action_code/status: {row['action_code']} / {row['status']}") print(f"conversation: {row.get('conversation_id')}") print(f"processo: {row.get('process_customer_hint')}") print(f"cliente_fiscal: {row.get('linked_customer_name') or '—'} · NIF {row.get('linked_customer_tax_id') or '—'} · {row.get('linked_customer_email') or '—'}") print(f"oportunidade.customer: {row.get('opportunity_customer_name') or '—'} · {row.get('opportunity_customer_email') or '—'}") print(f"contacto_chatwoot: {row.get('sender_name') or '—'} · {row.get('sender_email') or '—'}") print(f"titulo: {row.get('title')}") def apply_detach(suspects: list[dict[str, Any]], *, actor: str) -> int: now = datetime.now(timezone.utc).isoformat() by_opportunity: dict[str, list[dict[str, Any]]] = {} for row in suspects: opportunity_id = str(row.get("opportunity_id") or "").strip() if opportunity_id: by_opportunity.setdefault(opportunity_id, []).append(row) if not by_opportunity: return 0 with engine.begin() as conn: for opportunity_id, rows in by_opportunity.items(): sample = rows[0] audit_payload = { "detached_at": now, "detached_by": actor, "reason": "task_identity_mismatch_process_hint", "process_customer_hint": sample.get("process_customer_hint"), "previous_local_customer_id": sample.get("linked_customer_id"), "previous_linked_customer_name": sample.get("linked_customer_name"), "previous_linked_customer_email": sample.get("linked_customer_email"), "previous_opportunity_customer_name": sample.get("opportunity_customer_name"), "previous_opportunity_customer_email": sample.get("opportunity_customer_email"), "task_ids": [r.get("task_id") for r in rows], } conn.execute(text(""" UPDATE opportunities SET local_customer_id = NULL, customer_name = COALESCE(NULLIF(:process_customer_hint, ''), customer_name), customer_email = COALESCE(NULLIF(:sender_email, ''), customer_email), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('identity_detached', CAST(:audit_payload AS JSONB)), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "process_customer_hint": sample.get("process_customer_hint") or None, "sender_email": sample.get("sender_email") or None, "audit_payload": json.dumps(audit_payload, ensure_ascii=False), }) conn.execute(text(""" UPDATE tasks SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'identity_repair', CAST(:audit_payload AS JSONB), 'requires_fiscal_customer_review', true ), updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' """), { "opportunity_id": opportunity_id, "audit_payload": json.dumps(audit_payload, ensure_ascii=False), }) return len(by_opportunity) def main() -> int: parser = argparse.ArgumentParser(description="Audit/repair task identity mismatches") parser.add_argument("--apply", action="store_true", help="Detach only clearly unsafe fiscal customers from affected opportunities") parser.add_argument("--limit", type=int, default=500) parser.add_argument("--actor", default="admin") args = parser.parse_args() suspects = find_suspects(limit=args.limit) print_suspects(suspects) if not args.apply: if suspects: print("\nDry-run: nenhuma alteração aplicada. Usa --apply para desassociar clientes fiscais inseguros.") return 1 if suspects else 0 count = apply_detach(suspects, actor=args.actor) print(f"\nAlterações aplicadas: {count} oportunidade(s) desassociada(s) de cliente fiscal claramente inseguro.") remaining = find_suspects(limit=args.limit) if remaining: print("\nAinda há suspeitas após apply:") print_suspects(remaining) return 1 if remaining else 0 if __name__ == "__main__": raise SystemExit(main())