Files
clientflow_backend/app/company_opportunity_linking.py

436 lines
17 KiB
Python

"""Cross-conversation company/opportunity linking helpers.
v4928.1.5.91: B2B customers often use separate inboxes for buying and
finance (e.g. geral@empresa.pt orders, financeiro@empresa.pt pays). A new
Chatwoot conversation must therefore not automatically create a new opportunity
when it references an existing document/process.
Rules implemented here:
- exact document references (ORC/FA/FT formats) are strong automatic evidence;
- a business domain is company evidence only, never enough when multiple open
opportunities exist;
- public/ISP domains are ignored for identity linking.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import text
from app.db import engine
PUBLIC_EMAIL_DOMAINS = {
"gmail.com", "googlemail.com", "hotmail.com", "hotmail.pt", "outlook.com",
"outlook.pt", "live.com", "msn.com", "yahoo.com", "yahoo.pt", "icloud.com",
"me.com", "mac.com", "sapo.pt", "mail.telepac.pt", "netcabo.pt", "clix.pt",
"iol.pt", "vodafone.pt", "mail.com", "proton.me", "protonmail.com",
}
FINANCE_DEPARTMENT_PREFIXES = {
"financeiro", "financas", "finanças", "contabilidade", "accounts", "accounting",
"pagamentos", "payments", "billing", "facturacao", "faturacao", "faturação",
}
BUYING_DEPARTMENT_PREFIXES = {
"compras", "purchasing", "procurement", "encomendas", "orders", "geral", "info",
}
# Explicit stored formats: ORC.ORC2026.193, ORC2026.193, FA.FA2026.131, FT.FT2026.1
_EXPLICIT_DOC_RE = re.compile(
r"\b(?P<prefix>ORC|FA|FT|FR|NC|ND)[.\-/ ]?(?P=prefix)?\s*(?P<year>20\d{2})[.\-/ ]?0*(?P<num>\d{1,6})\b",
re.IGNORECASE,
)
# Human formats common in email subjects: Orçamento 2026/193, Fatura 2026/131.
_HUMAN_DOC_RE = re.compile(
r"\b(?P<label>or[cç]amento|orcamento|orc|cot[aá]?[cç][aã]o|proposta|fatura|factura|fat|invoice|ft|fa)\s+(?P<year>20\d{2})\s*/\s*0*(?P<num>\d{1,6})\b",
re.IGNORECASE,
)
PAYMENT_INTENT_RE = re.compile(
r"\b(comprovativo|pagamento|pago|transfer[êe]ncia|liquida[cç][aã]o|iban|recibo)\b",
re.IGNORECASE,
)
def _json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False, default=str)
def email_domain(email: object) -> str:
value = str(email or "").strip().lower()
if "@" not in value:
return ""
domain = value.rsplit("@", 1)[-1].strip(" .")
return domain
def email_local_part(email: object) -> str:
value = str(email or "").strip().lower()
if "@" not in value:
return ""
return value.split("@", 1)[0].strip()
def is_public_email_domain(domain_or_email: object) -> bool:
value = str(domain_or_email or "").strip().lower()
domain = email_domain(value) if "@" in value else value.strip(" .")
return bool(domain and domain in PUBLIC_EMAIL_DOMAINS)
def is_department_email(email: object) -> bool:
local = email_local_part(email)
if not local:
return False
root = re.split(r"[.\-_+0-9]", local, maxsplit=1)[0]
return root in FINANCE_DEPARTMENT_PREFIXES or root in BUYING_DEPARTMENT_PREFIXES
def _kind_for_prefix(prefix: str) -> str:
prefix = str(prefix or "").upper()
if prefix == "ORC":
return "quotation"
if prefix in {"FA", "FT"}:
return "invoice"
return "document"
def _prefix_for_label(label: str) -> str:
value = str(label or "").lower()
if any(term in value for term in ["fatura", "factura", "invoice", "fat", "ft", "fa"]):
return "FA"
return "ORC"
def normalize_document_reference(value: object) -> str:
"""Return canonical Jasmin-like document number when possible."""
text_value = str(value or "").strip()
if not text_value:
return ""
m = _EXPLICIT_DOC_RE.search(text_value)
if m:
prefix = m.group("prefix").upper()
year = m.group("year")
seq = int(m.group("num"))
return f"{prefix}.{prefix}{year}.{seq:03d}"
m = _HUMAN_DOC_RE.search(text_value)
if m:
prefix = _prefix_for_label(m.group("label"))
year = m.group("year")
seq = int(m.group("num"))
return f"{prefix}.{prefix}{year}.{seq:03d}"
return text_value
def document_reference_variants(document_number: object) -> List[str]:
normalized = normalize_document_reference(document_number)
if not normalized:
return []
variants: List[str] = [normalized]
m = re.match(r"^(?P<prefix>ORC|FA|FT|FR|NC|ND)\.(?P=prefix)(?P<year>20\d{2})\.(?P<num>\d{1,6})$", normalized, re.I)
if m:
prefix = m.group("prefix").upper()
year = m.group("year")
seq = int(m.group("num"))
label = "Orçamento" if prefix == "ORC" else "Fatura"
variants.extend([
f"{prefix}{year}.{seq:03d}",
f"{prefix} {year}/{seq}",
f"{label} {year}/{seq}",
f"{year}/{seq}",
f"{year[-2:]}/{seq}",
])
out: List[str] = []
for v in variants:
if v and v not in out:
out.append(v)
return out
def extract_document_references(*values: object) -> List[str]:
joined = "\n".join(str(v or "") for v in values if v is not None)
refs: List[str] = []
for regex in (_EXPLICIT_DOC_RE, _HUMAN_DOC_RE):
for m in regex.finditer(joined):
ref = normalize_document_reference(m.group(0))
if ref and ref not in refs:
refs.append(ref)
return refs
def task_text_blob(task: Dict[str, Any]) -> str:
return "\n".join(
str(task.get(k) or "")
for k in ("message_subject", "request_text", "note", "action", "customer_name", "customer_email")
)
def task_document_references(task: Dict[str, Any]) -> List[str]:
return extract_document_references(task_text_blob(task))
def is_payment_intent_task(task: Dict[str, Any]) -> bool:
code = str(task.get("action_code") or "").strip().upper()
if code in {"CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT", "SEND_PROFORMA", "SEND_INVOICE"}:
return True
return bool(PAYMENT_INTENT_RE.search(task_text_blob(task)))
def _unique_opportunities(rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
seen = set()
for row in rows:
opp_id = str(row.get("id") or row.get("opportunity_id") or "")
if not opp_id or opp_id in seen:
continue
seen.add(opp_id)
out.append(dict(row))
return out
def _candidate_payload(row: Dict[str, Any], *, reason: str) -> Dict[str, Any]:
return {
"opportunity_id": str(row.get("id") or row.get("opportunity_id") or ""),
"title": str(row.get("title") or row.get("opportunity_title") or ""),
"stage": str(row.get("stage") or ""),
"customer_name": str(row.get("customer_name") or ""),
"customer_email": str(row.get("customer_email") or ""),
"document_number": str(row.get("document_number") or ""),
"document_kind": str(row.get("document_kind") or ""),
"total_amount": str(row.get("total_amount") or row.get("value_amount") or ""),
"conversation_id": str(row.get("conversation_id") or ""),
"reason": reason,
}
def find_open_opportunities_by_document_references(refs: List[str]) -> List[Dict[str, Any]]:
refs = [normalize_document_reference(r) for r in refs if normalize_document_reference(r)]
if not refs:
return []
with engine.begin() as conn:
rows = conn.execute(text("""
WITH refs AS (
SELECT unnest(CAST(:refs AS TEXT[])) AS ref
), doc_matches AS (
SELECT
o.*, d.document_number, d.document_kind, d.total_amount, d.amount,
'commercial_document_reference'::text AS link_reason,
d.updated_at AS match_updated_at
FROM commercial_documents d
JOIN opportunities o ON o.id = d.opportunity_id
JOIN refs r ON upper(COALESCE(d.document_number, '')) = upper(r.ref)
WHERE o.status = 'open'
AND COALESCE(d.is_active, TRUE) = TRUE
AND d.opportunity_id IS NOT NULL
), recon_matches AS (
SELECT
o.*, ri.document_number, ri.external_type AS document_kind, ri.amount AS total_amount, ri.amount,
'reconciliation_document_reference'::text AS link_reason,
ri.updated_at AS match_updated_at
FROM reconciliation_items ri
JOIN opportunities o ON o.id = ri.opportunity_id
JOIN refs r ON upper(COALESCE(ri.document_number, '')) = upper(r.ref)
WHERE o.status = 'open'
AND ri.opportunity_id IS NOT NULL
)
SELECT * FROM doc_matches
UNION ALL
SELECT * FROM recon_matches
ORDER BY match_updated_at DESC NULLS LAST
LIMIT 20
"""), {"refs": refs}).mappings().all()
return _unique_opportunities([dict(r) for r in rows])
def find_document_owner_opportunity_for_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]:
refs = task_document_references(task)
if not refs:
return None
matches = find_open_opportunities_by_document_references(refs)
if len(matches) != 1:
return None
result = dict(matches[0])
result["_link_match_reason"] = "document_reference"
result["_document_reference_matches"] = refs
return result
def company_domain_for_task(task: Dict[str, Any]) -> str:
for key in ("customer_email", "opportunity_customer_email", "linked_customer_email"):
domain = email_domain(task.get(key))
if domain and not is_public_email_domain(domain):
return domain
return ""
def find_open_company_domain_candidates(task: Dict[str, Any], *, recent_days: int = 45, limit: int = 8) -> List[Dict[str, Any]]:
domain = company_domain_for_task(task)
if not domain:
return []
with engine.begin() as conn:
rows = conn.execute(text("""
SELECT DISTINCT ON (o.id)
o.id::text AS opportunity_id,
o.id,
o.title,
o.stage,
o.status,
o.customer_name,
COALESCE(NULLIF(o.customer_email, ''), NULLIF(c.email, '')) AS customer_email,
o.conversation_id,
o.value_amount,
d.document_number,
d.document_kind,
d.total_amount,
o.updated_at
FROM opportunities o
LEFT JOIN customers c ON c.id = o.local_customer_id
LEFT JOIN commercial_documents d ON d.opportunity_id = o.id AND COALESCE(d.is_active, TRUE) = TRUE
WHERE o.status = 'open'
AND o.updated_at >= now() - make_interval(days => :recent_days)
AND (
lower(COALESCE(o.customer_email, '')) LIKE :domain_like
OR lower(COALESCE(c.email, '')) LIKE :domain_like
OR lower(COALESCE(o.metadata::text, '')) LIKE :domain_text_like
)
ORDER BY o.id, d.updated_at DESC NULLS LAST, o.updated_at DESC
LIMIT :limit
"""), {
"domain_like": f"%@{domain}",
"domain_text_like": f"%{domain}%",
"recent_days": int(recent_days),
"limit": int(limit),
}).mappings().all()
return [dict(r) for r in rows]
def find_unique_company_domain_opportunity_for_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Auto-link only when the domain match is unique and the task is process-like.
This is deliberately conservative: public domains are ignored, and if more
than one opportunity exists for the same business domain the operator must
choose.
"""
if not is_payment_intent_task(task) and not is_department_email(task.get("customer_email")):
return None
candidates = find_open_company_domain_candidates(task, limit=3)
if len(candidates) != 1:
return None
result = dict(candidates[0])
result["_link_match_reason"] = "company_domain_unique_recent"
result["_company_domain"] = company_domain_for_task(task)
return result
def manual_link_candidates_for_task(task: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return candidates to show to the operator when automatic linking is unsafe."""
rows: List[Dict[str, Any]] = []
refs = task_document_references(task)
if refs:
rows.extend(find_open_opportunities_by_document_references(refs))
rows.extend(find_open_company_domain_candidates(task, limit=8))
out: List[Dict[str, Any]] = []
seen = set()
for row in rows:
opp_id = str(row.get("id") or row.get("opportunity_id") or "")
if not opp_id or opp_id in seen:
continue
seen.add(opp_id)
reason = "referência documental" if str(row.get("document_number") or "") else "mesmo domínio empresarial"
out.append(_candidate_payload(row, reason=reason))
return out
def mark_task_ambiguous_with_candidates(task_id: str, *, reason: str, candidates: List[Dict[str, Any]], created_by: str = "system") -> None:
metadata = {
"opportunity_linking_status": "ambiguous",
"opportunity_linking_reason": reason,
"opportunity_linking_candidates": candidates[:8],
"opportunity_linking_checked_by": created_by,
}
with engine.begin() as conn:
conn.execute(text("""
UPDATE tasks
SET route = CASE WHEN status = 'pending' THEN 'rever' ELSE route END,
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE id = CAST(:task_id AS UUID)
"""), {"task_id": task_id, "metadata": _json(metadata)})
def associate_task_to_opportunity(task_id: str, opportunity_id: str, *, actor: str = "operator") -> Dict[str, int]:
"""Manual operator action: attach a task/conversation evidence to an existing opportunity."""
metadata = {
"opportunity_linking_status": "resolved",
"opportunity_linking_resolved_by": actor,
"opportunity_linking_resolved_reason": "operator_associated_existing_opportunity",
"opportunity_id": str(opportunity_id),
}
with engine.begin() as conn:
task = conn.execute(text("""
SELECT id::text, communication_id::text, message_id::text, conversation_id, contact_id, metadata
FROM tasks
WHERE id = CAST(:task_id AS UUID)
LIMIT 1
"""), {"task_id": task_id}).mappings().first()
if not task:
raise ValueError(f"task_not_found: {task_id}")
opp = conn.execute(text("""
SELECT id::text, title, conversation_id
FROM opportunities
WHERE id = CAST(:opportunity_id AS UUID)
LIMIT 1
"""), {"opportunity_id": opportunity_id}).mappings().first()
if not opp:
raise ValueError(f"opportunity_not_found: {opportunity_id}")
task_result = conn.execute(text("""
UPDATE tasks
SET opportunity_id = CAST(:opportunity_id AS UUID),
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE id = CAST(:task_id AS UUID)
"""), {"task_id": task_id, "opportunity_id": opportunity_id, "metadata": _json(metadata)})
comm_result = conn.execute(text("""
UPDATE communications
SET opportunity_id = CAST(:opportunity_id AS UUID),
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE task_id = CAST(:task_id AS UUID)
OR id = NULLIF(:communication_id, '')::uuid
OR source_message_id = NULLIF(:message_id, '')
OR (conversation_id = NULLIF(:conversation_id, '') AND opportunity_id IS NULL)
"""), {
"task_id": task_id,
"opportunity_id": opportunity_id,
"communication_id": task.get("communication_id") or "",
"message_id": task.get("message_id") or "",
"conversation_id": task.get("conversation_id") or "",
"metadata": _json(metadata),
})
conn.execute(text("""
UPDATE opportunities
SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now(),
last_task_id = CAST(:task_id AS UUID)
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"task_id": task_id,
"metadata": _json({"last_manual_task_association": metadata}),
})
conn.execute(text("""
INSERT INTO opportunity_events (id, opportunity_id, event_type, task_id, action_code, note, payload, created_by)
SELECT gen_random_uuid(), CAST(:opportunity_id AS UUID), 'manual_task_association', CAST(:task_id AS UUID),
action_code, 'Operador associou tarefa/conversa a oportunidade existente.', CAST(:metadata AS JSONB), :actor
FROM tasks
WHERE id = CAST(:task_id AS UUID)
"""), {"opportunity_id": opportunity_id, "task_id": task_id, "metadata": _json(metadata), "actor": actor})
return {"tasks": int(task_result.rowcount or 0), "communications": int(comm_result.rowcount or 0)}