Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View 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())