Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
266
scripts/audit_task_identity_mismatch.py
Executable file
266
scripts/audit_task_identity_mismatch.py
Executable file
@@ -0,0 +1,266 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user