1268 lines
60 KiB
Python
1268 lines
60 KiB
Python
"""Fiscal enrichment worker for ClientFlow opportunities.
|
|
|
|
v4.9.25 adds a small, autonomous layer that runs before reconciliation:
|
|
|
|
- read open opportunities without a fiscal customer;
|
|
- use local data/cache first;
|
|
- consult the external company/contact lookup API when enabled;
|
|
- create an auditable suggestion;
|
|
- auto-associate only very strong, non-conflicting matches.
|
|
|
|
The service is intentionally conservative. It enriches the fiscal identity used
|
|
by Jasmin/Odoo reconciliation; it does not create/close opportunities or alter
|
|
commercial stages.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.config import settings
|
|
from app.db import engine
|
|
from app.commercial_service import (
|
|
get_customer_by_tax_id,
|
|
link_customer_to_opportunity,
|
|
normalize_fiscal_name,
|
|
normalize_tax_id,
|
|
upsert_customer,
|
|
)
|
|
from app.opportunity_service import ensure_opportunity_schema, get_opportunity, list_opportunities
|
|
from app.email_identity_extraction_service import extract_identity_for_opportunity, is_plausible_company_mention
|
|
|
|
|
|
CACHE_SOURCE = "informa_pipeline_api"
|
|
DEFAULT_AUTO_THRESHOLD = 95.0
|
|
PUBLIC_EMAIL_DOMAINS = {
|
|
"gmail.com", "googlemail.com", "hotmail.com", "hotmail.pt", "outlook.com",
|
|
"outlook.pt", "live.com", "msn.com", "icloud.com", "me.com", "mac.com",
|
|
"yahoo.com", "yahoo.pt", "sapo.pt", "mail.com", "proton.me", "protonmail.com",
|
|
"aol.com", "gmx.com", "gmx.net", "uol.com.br",
|
|
}
|
|
VERY_STRONG_MATCH_TYPES = {
|
|
"nif_exato",
|
|
"email_exato",
|
|
"contacto_email_exato",
|
|
"email_principal_exato",
|
|
"email_exato_empresa_inferida",
|
|
"email_exato_empresa_associada",
|
|
"empresa_email_principal_exato",
|
|
"email_identity_company_internal",
|
|
}
|
|
STRONG_MATCH_TYPES = VERY_STRONG_MATCH_TYPES | {
|
|
"nome_exato",
|
|
"email_principal_dominio",
|
|
"website_dominio",
|
|
"contacto_email_dominio",
|
|
}
|
|
MEDIUM_MATCH_TYPES = STRONG_MATCH_TYPES | {"prefixo_nome", "parte_nome", "dominio", "email_dominio", "contacto"}
|
|
DOMAIN_ONLY_MATCH_TYPES = {"email_principal_dominio", "email_dominio_empresa_associada", "contacto_email_dominio", "dominio", "email_dominio", "website_dominio"}
|
|
|
|
_SCHEMA_READY = False
|
|
|
|
|
|
def _clean(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
|
|
|
|
|
def _float(value: Any, default: float = 0.0) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _sha256_payload(value: Any) -> str:
|
|
raw = json.dumps(value or {}, ensure_ascii=False, sort_keys=True, default=str)
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _domain_from_email(email: Any) -> str:
|
|
email_value = _clean(email).lower()
|
|
if "@" not in email_value:
|
|
return ""
|
|
return normalize_domain(email_value.rsplit("@", 1)[1])
|
|
|
|
|
|
def normalize_domain(value: Any) -> str:
|
|
raw = _clean(value).lower()
|
|
if not raw:
|
|
return ""
|
|
raw = raw.replace("https://", "").replace("http://", "")
|
|
raw = raw.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
|
|
raw = raw.strip(". ")
|
|
if raw.startswith("www."):
|
|
raw = raw[4:]
|
|
return raw
|
|
|
|
|
|
def is_public_email_domain(domain: Any) -> bool:
|
|
return normalize_domain(domain) in PUBLIC_EMAIL_DOMAINS
|
|
|
|
|
|
def ensure_fiscal_enrichment_schema() -> None:
|
|
"""Create the cache/suggestion/audit tables used by the worker."""
|
|
global _SCHEMA_READY
|
|
if _SCHEMA_READY:
|
|
return
|
|
ensure_opportunity_schema()
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS external_company_cache (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source TEXT NOT NULL DEFAULT 'informa_pipeline_api',
|
|
lookup_type TEXT NOT NULL,
|
|
lookup_value TEXT NOT NULL,
|
|
external_id TEXT,
|
|
nif TEXT,
|
|
legal_name TEXT,
|
|
normalized_name TEXT,
|
|
cae TEXT,
|
|
address TEXT,
|
|
postcode TEXT,
|
|
city TEXT,
|
|
district TEXT,
|
|
country TEXT,
|
|
website TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
duns TEXT,
|
|
status TEXT,
|
|
score NUMERIC(5,2),
|
|
match_type TEXT,
|
|
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
payload_hash TEXT,
|
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
expires_at TIMESTAMPTZ
|
|
)
|
|
"""))
|
|
for stmt in [
|
|
"ALTER TABLE external_company_cache ADD COLUMN IF NOT EXISTS payload_hash TEXT",
|
|
"ALTER TABLE external_company_cache ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ",
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_external_company_cache_lookup ON external_company_cache(source, lookup_type, lookup_value)",
|
|
"CREATE INDEX IF NOT EXISTS idx_external_company_cache_nif ON external_company_cache(nif) WHERE nif IS NOT NULL AND nif <> ''",
|
|
"CREATE INDEX IF NOT EXISTS idx_external_company_cache_name ON external_company_cache(normalized_name)",
|
|
]:
|
|
conn.execute(text(stmt))
|
|
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS fiscal_customer_suggestions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
opportunity_id UUID REFERENCES opportunities(id) ON DELETE CASCADE,
|
|
reconciliation_item_id UUID,
|
|
suggested_customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
|
suggested_nif TEXT,
|
|
suggested_name TEXT NOT NULL,
|
|
source TEXT NOT NULL DEFAULT 'informa_pipeline_api',
|
|
lookup_type TEXT,
|
|
lookup_value TEXT,
|
|
match_type TEXT,
|
|
confidence NUMERIC(5,2) NOT NULL DEFAULT 0,
|
|
reason TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
auto_applied BOOLEAN NOT NULL DEFAULT FALSE,
|
|
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
resolved_at TIMESTAMPTZ,
|
|
resolved_by TEXT
|
|
)
|
|
"""))
|
|
for stmt in [
|
|
"ALTER TABLE fiscal_customer_suggestions ADD COLUMN IF NOT EXISTS suggested_customer_id UUID REFERENCES customers(id) ON DELETE SET NULL",
|
|
"ALTER TABLE fiscal_customer_suggestions ADD COLUMN IF NOT EXISTS auto_applied BOOLEAN NOT NULL DEFAULT FALSE",
|
|
"ALTER TABLE fiscal_customer_suggestions ADD COLUMN IF NOT EXISTS lookup_type TEXT",
|
|
"ALTER TABLE fiscal_customer_suggestions ADD COLUMN IF NOT EXISTS lookup_value TEXT",
|
|
"ALTER TABLE fiscal_customer_suggestions ADD COLUMN IF NOT EXISTS resolved_by TEXT",
|
|
"CREATE INDEX IF NOT EXISTS idx_fiscal_customer_suggestions_opp ON fiscal_customer_suggestions(opportunity_id, status)",
|
|
"CREATE INDEX IF NOT EXISTS idx_fiscal_customer_suggestions_status ON fiscal_customer_suggestions(status, created_at DESC)",
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_fiscal_customer_suggestion_opp_nif_pending ON fiscal_customer_suggestions(opportunity_id, suggested_nif) WHERE suggested_nif IS NOT NULL AND suggested_nif <> '' AND status = 'pending'",
|
|
]:
|
|
conn.execute(text(stmt))
|
|
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS fiscal_enrichment_runs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
mode TEXT NOT NULL DEFAULT 'incremental',
|
|
status TEXT NOT NULL DEFAULT 'finished',
|
|
seen INTEGER NOT NULL DEFAULT 0,
|
|
enriched INTEGER NOT NULL DEFAULT 0,
|
|
suggested INTEGER NOT NULL DEFAULT 0,
|
|
auto_applied INTEGER NOT NULL DEFAULT 0,
|
|
skipped INTEGER NOT NULL DEFAULT 0,
|
|
errors JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
finished_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""))
|
|
_SCHEMA_READY = True
|
|
|
|
|
|
def _external_enabled() -> bool:
|
|
return bool(getattr(settings, "external_company_lookup_enabled", False)) and bool(_clean(getattr(settings, "external_company_lookup_base_url", "")))
|
|
|
|
|
|
def _api_base_url() -> str:
|
|
return _clean(getattr(settings, "external_company_lookup_base_url", "")).rstrip("/")
|
|
|
|
|
|
def _api_key() -> str:
|
|
return _clean(getattr(settings, "external_company_lookup_api_key", ""))
|
|
|
|
|
|
def _timeout() -> float:
|
|
return max(float(getattr(settings, "external_company_lookup_timeout", 10) or 10), 1.0)
|
|
|
|
|
|
def _auto_threshold() -> float:
|
|
return float(getattr(settings, "external_company_lookup_auto_threshold", DEFAULT_AUTO_THRESHOLD) or DEFAULT_AUTO_THRESHOLD)
|
|
|
|
|
|
def _http_json(method: str, path: str, *, params: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
if not _external_enabled():
|
|
return {"_skipped": "EXTERNAL_COMPANY_LOOKUP_ENABLED=false"}
|
|
url = _api_base_url() + path
|
|
if params:
|
|
query = urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}, doseq=True)
|
|
if query:
|
|
url += "?" + query
|
|
headers = {"Accept": "application/json"}
|
|
if _api_key():
|
|
headers["X-API-Key"] = _api_key()
|
|
data = None
|
|
if payload is not None:
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=_timeout()) as response: # noqa: S310 - configured internal API endpoint
|
|
raw = response.read().decode("utf-8")
|
|
return json.loads(raw) if raw else {}
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return {"_not_found": True, "status_code": 404}
|
|
body = ""
|
|
try:
|
|
body = exc.read().decode("utf-8")[:500]
|
|
except Exception:
|
|
body = ""
|
|
return {"_error": f"HTTP {exc.code}", "body": body}
|
|
except Exception as exc:
|
|
return {"_error": str(exc)}
|
|
|
|
|
|
def _company_from_contact_item(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
empresa = item.get("empresa")
|
|
if not isinstance(empresa, dict) or not empresa:
|
|
return None
|
|
company = dict(empresa)
|
|
company["score"] = _float(item.get("confidence"), _float(company.get("score"), 0.0))
|
|
company["match_type"] = _clean(item.get("match_type") or item.get("empresa_resolution_type") or company.get("match_type"))
|
|
company["empresa_resolution_type"] = item.get("empresa_resolution_type")
|
|
company["contacto_payload"] = item.get("contacto") or {}
|
|
return company
|
|
|
|
|
|
def _best_company_from_response(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not isinstance(data, dict) or data.get("_error") or data.get("_skipped"):
|
|
return None
|
|
if data.get("nome") or data.get("nif"):
|
|
company = dict(data)
|
|
company.setdefault("score", 100.0 if data.get("nif") else 0.0)
|
|
company.setdefault("match_type", "nif_exato" if data.get("nif") else "")
|
|
return company
|
|
contacts = data.get("contactos")
|
|
if isinstance(contacts, list) and contacts:
|
|
candidates = [_company_from_contact_item(x) for x in contacts if isinstance(x, dict)]
|
|
candidates = [x for x in candidates if x]
|
|
if candidates:
|
|
return sorted(candidates, key=lambda c: _float(c.get("score")), reverse=True)[0]
|
|
companies = data.get("empresas") or data.get("matches")
|
|
if isinstance(companies, list) and companies:
|
|
candidates = [x for x in companies if isinstance(x, dict)]
|
|
if candidates:
|
|
return sorted(candidates, key=lambda c: _float(c.get("score")), reverse=True)[0]
|
|
return None
|
|
|
|
|
|
def _company_to_customer_data(company: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"name": _clean(company.get("nome") or company.get("legal_name") or company.get("name")),
|
|
"tax_id": normalize_tax_id(company.get("nif") or company.get("tax_id")),
|
|
"email": _clean(company.get("email_principal") or company.get("email")),
|
|
"phone": _clean(company.get("telefone") or company.get("phone")),
|
|
"street_name": _clean(company.get("morada") or company.get("address")),
|
|
"postal_zone": _clean(company.get("codigo_postal") or company.get("postcode")),
|
|
"city_name": _clean(company.get("localidade") or company.get("city")),
|
|
"country": _clean(company.get("pais") or company.get("country") or "PT") or "PT",
|
|
"metadata": {
|
|
"fiscal_enrichment_source": CACHE_SOURCE,
|
|
"external_company_id": company.get("id"),
|
|
"cae": company.get("cae"),
|
|
"duns": company.get("duns"),
|
|
"website": company.get("website"),
|
|
"district": company.get("distrito") or company.get("district"),
|
|
"einforma_estado": company.get("einforma_estado"),
|
|
"last_fiscal_enrichment_at": _now_iso(),
|
|
},
|
|
}
|
|
|
|
|
|
def _cache_lookup(lookup_type: str, lookup_value: str) -> Optional[Dict[str, Any]]:
|
|
ensure_fiscal_enrichment_schema()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT raw_payload
|
|
FROM external_company_cache
|
|
WHERE source = :source AND lookup_type = :lookup_type AND lookup_value = :lookup_value
|
|
AND (expires_at IS NULL OR expires_at > now())
|
|
ORDER BY fetched_at DESC
|
|
LIMIT 1
|
|
"""), {"source": CACHE_SOURCE, "lookup_type": lookup_type, "lookup_value": lookup_value}).mappings().first()
|
|
if row and isinstance(row.get("raw_payload"), dict):
|
|
return dict(row["raw_payload"])
|
|
return None
|
|
|
|
|
|
def _cache_company(lookup_type: str, lookup_value: str, company: Dict[str, Any]) -> None:
|
|
ensure_fiscal_enrichment_schema()
|
|
if not lookup_value or not company:
|
|
return
|
|
normalized_name = normalize_fiscal_name(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
params = {
|
|
"source": CACHE_SOURCE,
|
|
"lookup_type": lookup_type,
|
|
"lookup_value": lookup_value,
|
|
"external_id": _clean(company.get("id")),
|
|
"nif": normalize_tax_id(company.get("nif") or company.get("tax_id")) or None,
|
|
"legal_name": _clean(company.get("nome") or company.get("legal_name") or company.get("name")) or None,
|
|
"normalized_name": normalized_name or None,
|
|
"cae": _clean(company.get("cae")) or None,
|
|
"address": _clean(company.get("morada") or company.get("address")) or None,
|
|
"postcode": _clean(company.get("codigo_postal") or company.get("postcode")) or None,
|
|
"city": _clean(company.get("localidade") or company.get("city")) or None,
|
|
"district": _clean(company.get("distrito") or company.get("district")) or None,
|
|
"country": _clean(company.get("pais") or company.get("country") or "PT") or "PT",
|
|
"website": _clean(company.get("website")) or None,
|
|
"phone": _clean(company.get("telefone") or company.get("phone")) or None,
|
|
"email": _clean(company.get("email_principal") or company.get("email")) or None,
|
|
"duns": _clean(company.get("duns")) or None,
|
|
"status": _clean(company.get("einforma_estado") or company.get("status")) or None,
|
|
"score": _float(company.get("score"), 0.0),
|
|
"match_type": _clean(company.get("match_type")) or None,
|
|
"raw_payload": _json(company),
|
|
"payload_hash": _sha256_payload(company),
|
|
}
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO external_company_cache (
|
|
source, lookup_type, lookup_value, external_id, nif, legal_name, normalized_name,
|
|
cae, address, postcode, city, district, country, website, phone, email, duns,
|
|
status, score, match_type, raw_payload, payload_hash, fetched_at, expires_at
|
|
) VALUES (
|
|
:source, :lookup_type, :lookup_value, :external_id, :nif, :legal_name, :normalized_name,
|
|
:cae, :address, :postcode, :city, :district, :country, :website, :phone, :email, :duns,
|
|
:status, :score, :match_type, CAST(:raw_payload AS JSONB), :payload_hash, now(), now() + interval '30 days'
|
|
)
|
|
ON CONFLICT (source, lookup_type, lookup_value)
|
|
DO UPDATE SET
|
|
external_id = EXCLUDED.external_id,
|
|
nif = EXCLUDED.nif,
|
|
legal_name = EXCLUDED.legal_name,
|
|
normalized_name = EXCLUDED.normalized_name,
|
|
cae = EXCLUDED.cae,
|
|
address = EXCLUDED.address,
|
|
postcode = EXCLUDED.postcode,
|
|
city = EXCLUDED.city,
|
|
district = EXCLUDED.district,
|
|
country = EXCLUDED.country,
|
|
website = EXCLUDED.website,
|
|
phone = EXCLUDED.phone,
|
|
email = EXCLUDED.email,
|
|
duns = EXCLUDED.duns,
|
|
status = EXCLUDED.status,
|
|
score = EXCLUDED.score,
|
|
match_type = EXCLUDED.match_type,
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
payload_hash = EXCLUDED.payload_hash,
|
|
fetched_at = now(),
|
|
expires_at = EXCLUDED.expires_at
|
|
"""), params)
|
|
|
|
|
|
|
|
def batch_lookup_companies(*, nifs: Iterable[str] = (), names: Iterable[str] = (), emails: Iterable[str] = (), domains: Iterable[str] = (), websites: Iterable[str] = (), limit_per_query: int = 5) -> Dict[str, Any]:
|
|
"""Use POST /empresas/batch for large offline enrichment/reconciliation runs.
|
|
|
|
The incremental worker prefers single lookups so it can stop as soon as it
|
|
finds a strong match. Batch lookup is exposed for future nightly jobs and
|
|
tests/manual diagnostics.
|
|
"""
|
|
payload = {
|
|
"nifs": [normalize_tax_id(x) for x in nifs if normalize_tax_id(x)],
|
|
"names": [_clean(x) for x in names if _clean(x)],
|
|
"emails": [_clean(x).lower() for x in emails if _clean(x)],
|
|
"domains": [normalize_domain(x) for x in domains if normalize_domain(x) and not is_public_email_domain(x)],
|
|
"websites": [_clean(x) for x in websites if _clean(x)],
|
|
"include_contactos": False,
|
|
"limit_per_query": max(min(int(limit_per_query or 5), 20), 1),
|
|
}
|
|
payload = {k: v for k, v in payload.items() if v not in ([], "", None)}
|
|
if not any(k in payload for k in ("nifs", "names", "emails", "domains", "websites")):
|
|
return {"total_inputs": 0, "results": {}}
|
|
return _http_json("POST", "/empresas/batch", payload=payload)
|
|
|
|
def _lookup_external_by_nif(nif: str) -> Optional[Dict[str, Any]]:
|
|
nif = normalize_tax_id(nif)
|
|
if not nif:
|
|
return None
|
|
cached = _cache_lookup("nif", nif)
|
|
if cached:
|
|
return cached
|
|
data = _http_json("GET", f"/empresas/nif/{urllib.parse.quote(nif)}", params={"include_contactos": "false"})
|
|
company = _best_company_from_response(data)
|
|
if company:
|
|
company.setdefault("score", 100.0)
|
|
company.setdefault("match_type", "nif_exato")
|
|
_cache_company("nif", nif, company)
|
|
return company
|
|
|
|
|
|
def _lookup_external_by_email(email: str) -> Optional[Dict[str, Any]]:
|
|
email = _clean(email).lower()
|
|
if not email or "@" not in email:
|
|
return None
|
|
cached = _cache_lookup("email", email)
|
|
if cached:
|
|
return cached
|
|
data = _http_json("GET", "/contactos/search", params={"email": email, "limit": 10})
|
|
company = _best_company_from_response(data)
|
|
if company:
|
|
_cache_company("email", email, company)
|
|
return company
|
|
domain = _domain_from_email(email)
|
|
if domain and not is_public_email_domain(domain):
|
|
return _lookup_external_by_domain(domain)
|
|
return None
|
|
|
|
|
|
def _lookup_external_by_domain(domain: str) -> Optional[Dict[str, Any]]:
|
|
domain = normalize_domain(domain)
|
|
if not domain or is_public_email_domain(domain):
|
|
return None
|
|
cached = _cache_lookup("domain", domain)
|
|
if cached:
|
|
return cached
|
|
data = _http_json("GET", f"/empresas/domain/{urllib.parse.quote(domain)}", params={"limit": 10, "include_contactos": "false"})
|
|
company = _best_company_from_response(data)
|
|
if company:
|
|
_cache_company("domain", domain, company)
|
|
return company
|
|
data = _http_json("GET", "/empresas/website", params={"domain": domain, "limit": 10, "include_contactos": "false"})
|
|
company = _best_company_from_response(data)
|
|
if company:
|
|
_cache_company("domain", domain, company)
|
|
return company
|
|
|
|
|
|
def _lookup_external_by_name(name: str) -> Optional[Dict[str, Any]]:
|
|
name = _clean(name)
|
|
if len(name) < 3:
|
|
return None
|
|
normalized = normalize_fiscal_name(name)
|
|
cached = _cache_lookup("name", normalized)
|
|
if cached:
|
|
return cached
|
|
data = _http_json("GET", "/empresas/search", params={"q": name, "limit": 10, "include_contactos": "false"})
|
|
company = _best_company_from_response(data)
|
|
if company:
|
|
_cache_company("name", normalized, company)
|
|
return company
|
|
|
|
|
|
def _source_signals_from_opportunity(opportunity: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {}
|
|
signals: List[Tuple[str, str]] = []
|
|
for key in ("customer_tax_id", "tax_id", "nif"):
|
|
value = normalize_tax_id(metadata.get(key) or opportunity.get(key))
|
|
if value:
|
|
signals.append(("nif", value))
|
|
email = _clean(opportunity.get("customer_email") or metadata.get("customer_email") or metadata.get("email")).lower()
|
|
if email and "@" in email:
|
|
signals.append(("email", email))
|
|
domain = _domain_from_email(email)
|
|
if domain and not is_public_email_domain(domain):
|
|
signals.append(("domain", domain))
|
|
for key in ("website", "url"):
|
|
domain = normalize_domain(metadata.get(key) or opportunity.get(key))
|
|
if domain and not is_public_email_domain(domain):
|
|
signals.append(("domain", domain))
|
|
for value in (opportunity.get("linked_customer_tax_id"),):
|
|
nif = normalize_tax_id(value)
|
|
if nif:
|
|
signals.append(("nif", nif))
|
|
name = _clean(opportunity.get("customer_name") or metadata.get("customer_name") or opportunity.get("title"))
|
|
if name:
|
|
signals.append(("name", name))
|
|
# stable de-dup preserving order
|
|
result: List[Tuple[str, str]] = []
|
|
seen = set()
|
|
for kind, value in signals:
|
|
key = (kind, value.casefold())
|
|
if value and key not in seen:
|
|
seen.add(key)
|
|
result.append((kind, value))
|
|
return result
|
|
|
|
|
|
|
|
def _company_from_customer_row(row: Dict[str, Any], *, match_type: str = "email_identity_company_internal", score: float = 96.0) -> Dict[str, Any]:
|
|
"""Represent an existing ClientFlow customer as a company candidate."""
|
|
return {
|
|
"id": _clean(row.get("id")),
|
|
"nome": _clean(row.get("name")),
|
|
"nif": normalize_tax_id(row.get("tax_id")),
|
|
"email_principal": _clean(row.get("email")),
|
|
"telefone": _clean(row.get("phone")),
|
|
"morada": _clean(row.get("street_name")),
|
|
"codigo_postal": _clean(row.get("postal_zone")),
|
|
"localidade": _clean(row.get("city_name")),
|
|
"pais": _clean(row.get("country") or "Portugal"),
|
|
"score": score,
|
|
"match_type": match_type,
|
|
"clientflow_customer_id": _clean(row.get("id")),
|
|
"source": "clientflow_internal_identity",
|
|
}
|
|
|
|
|
|
|
|
|
|
def _identity_company_mentions(identity: Optional[Dict[str, Any]]) -> List[str]:
|
|
if not identity or not isinstance(identity.get("company_mentions"), list):
|
|
return []
|
|
out: List[str] = []
|
|
for value in identity.get("company_mentions") or []:
|
|
cleaned = _clean(value)
|
|
if cleaned and is_plausible_company_mention(cleaned):
|
|
out.append(cleaned)
|
|
return out
|
|
|
|
|
|
def _normalized_identity_mentions(identity: Optional[Dict[str, Any]]) -> List[str]:
|
|
return [normalize_fiscal_name(x) for x in _identity_company_mentions(identity) if normalize_fiscal_name(x)]
|
|
|
|
|
|
def _company_name_matches_mention(mention_norm: str, candidate_norm: str) -> bool:
|
|
if not mention_norm or not candidate_norm:
|
|
return False
|
|
if len(mention_norm) < 4 or len(candidate_norm) < 4:
|
|
return False
|
|
if mention_norm == candidate_norm:
|
|
return True
|
|
# Substring matching is useful for variants like "Dietimport S.A" vs
|
|
# "DIETIMPORT, S.A.", but dangerous for tiny tokens such as "pt".
|
|
if len(mention_norm) >= 5 and len(candidate_norm) >= 5:
|
|
if mention_norm in candidate_norm or candidate_norm in mention_norm:
|
|
return True
|
|
mention_tokens = {t for t in mention_norm.split() if len(t) >= 5 and t not in {"unipessoal", "limitada"}}
|
|
candidate_tokens = {t for t in candidate_norm.split() if len(t) >= 5 and t not in {"unipessoal", "limitada"}}
|
|
return bool(mention_tokens & candidate_tokens)
|
|
|
|
def _find_internal_customer_by_identity(identity: Optional[Dict[str, Any]], opportunity: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""Prefer explicit company evidence found in the email body/signature.
|
|
|
|
This prevents a weak external domain match from winning when the email text
|
|
itself mentions a company already known by ClientFlow/Odoo/Jasmin, e.g.
|
|
Dietimport S.A. in the legal disclaimer.
|
|
"""
|
|
if not identity:
|
|
return None
|
|
mentions = _identity_company_mentions(identity)
|
|
normalized_mentions = _normalized_identity_mentions(identity)
|
|
domain = normalize_domain(identity.get("domain") or _domain_from_email(opportunity.get("customer_email")))
|
|
if not normalized_mentions and not domain:
|
|
return None
|
|
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(text("""
|
|
SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, metadata
|
|
FROM customers
|
|
WHERE (tax_id IS NOT NULL AND tax_id <> '')
|
|
OR (email IS NOT NULL AND email <> '')
|
|
OR name IS NOT NULL
|
|
ORDER BY updated_at DESC
|
|
LIMIT 2000
|
|
""")).mappings().all()
|
|
|
|
best: Optional[Dict[str, Any]] = None
|
|
best_score = 0.0
|
|
for row in rows:
|
|
name_norm = normalize_fiscal_name(row.get("name"))
|
|
email_domain = _domain_from_email(row.get("email"))
|
|
score = 0.0
|
|
for mention in normalized_mentions:
|
|
if mention and name_norm:
|
|
if mention == name_norm:
|
|
score = max(score, 98.0)
|
|
elif _company_name_matches_mention(mention, name_norm):
|
|
score = max(score, 92.0)
|
|
if domain and email_domain and domain == email_domain:
|
|
score += 3.0 if score else 70.0
|
|
# same address reinforces an explicit company mention
|
|
if score >= 90 and _clean(identity.get("address")) and _clean(row.get("street_name")):
|
|
if normalize_fiscal_name(row.get("street_name")) in normalize_fiscal_name(identity.get("address")):
|
|
score += 2.0
|
|
if score > best_score:
|
|
best_score = min(score, 99.0)
|
|
best = dict(row)
|
|
if not best or best_score < 90:
|
|
return None
|
|
return _company_from_customer_row(best, score=best_score)
|
|
|
|
|
|
def _identity_company_conflict(identity: Optional[Dict[str, Any]], company: Dict[str, Any]) -> bool:
|
|
"""Detect when an external candidate conflicts with explicit email identity."""
|
|
if not identity:
|
|
return False
|
|
mentions = _identity_company_mentions(identity)
|
|
normalized_mentions = _normalized_identity_mentions(identity)
|
|
if not normalized_mentions:
|
|
return False
|
|
candidate_name = normalize_fiscal_name(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
if not candidate_name:
|
|
return False
|
|
for mention in normalized_mentions:
|
|
if _company_name_matches_mention(mention, candidate_name):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _identity_mentions_match_name(identity: Optional[Dict[str, Any]], name: Any) -> bool:
|
|
if not identity or not _clean(name):
|
|
return False
|
|
candidate_name = normalize_fiscal_name(name)
|
|
mentions = _identity_company_mentions(identity)
|
|
for mention in mentions:
|
|
mention_norm = normalize_fiscal_name(mention)
|
|
if _company_name_matches_mention(mention_norm, candidate_name):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _identity_conflicts_with_linked_customer(identity: Optional[Dict[str, Any]], opportunity: Dict[str, Any]) -> bool:
|
|
linked_name = _clean(opportunity.get("linked_customer_name"))
|
|
mentions = _identity_company_mentions(identity)
|
|
if not linked_name or not mentions:
|
|
return False
|
|
return not _identity_mentions_match_name(identity, linked_name)
|
|
|
|
|
|
def email_identity_review_for_opportunity(opportunity_id: str, *, refresh: bool = False) -> Dict[str, Any]:
|
|
"""Return the operator-facing identity review for an opportunity.
|
|
|
|
This is read-only except when refresh=True, where it stores a new extraction.
|
|
It intentionally runs even if a fiscal customer is already linked so the UI
|
|
can show cases like: email mentions Dietimport S.A. but the opportunity is
|
|
linked to Fmrl - Imobiliária S.A.
|
|
"""
|
|
ensure_fiscal_enrichment_schema()
|
|
opportunity = get_opportunity(opportunity_id)
|
|
if not opportunity:
|
|
return {"ok": False, "reason": "opportunity_not_found"}
|
|
try:
|
|
identity = extract_identity_for_opportunity(
|
|
opportunity_id,
|
|
refresh=refresh,
|
|
use_llm=bool(getattr(settings, "email_identity_extraction_use_llm", True)),
|
|
)
|
|
except Exception as exc:
|
|
return {"ok": False, "reason": f"email_identity_extraction_failed: {exc}"}
|
|
|
|
valid_mentions = _identity_company_mentions(identity)
|
|
identity_company = _find_internal_customer_by_identity(identity, opportunity) if valid_mentions else None
|
|
linked_customer_id = _clean(opportunity.get("linked_customer_id") or opportunity.get("local_customer_id"))
|
|
suggested_customer_id = _clean(identity_company.get("clientflow_customer_id")) if identity_company else ""
|
|
conflict = _identity_conflicts_with_linked_customer(identity, opportunity) if valid_mentions else False
|
|
if conflict and suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id:
|
|
conflict = False
|
|
|
|
return {
|
|
"ok": True,
|
|
"opportunity_id": opportunity_id,
|
|
"identity": identity,
|
|
"valid_company_mentions": valid_mentions,
|
|
"linked_customer_id": linked_customer_id,
|
|
"linked_customer_name": opportunity.get("linked_customer_name"),
|
|
"linked_customer_tax_id": opportunity.get("linked_customer_tax_id"),
|
|
"suggested_internal_customer": identity_company,
|
|
"conflict": conflict,
|
|
"status": "conflict" if conflict else ("suggestion" if identity_company else "identity_only"),
|
|
}
|
|
|
|
|
|
def assist_email_identity_enrichment(opportunity_id: str, *, refresh: bool = True, apply_safe: bool = False) -> Dict[str, Any]:
|
|
"""Use extracted email identity to create an assisted fiscal suggestion.
|
|
|
|
v4.9.26.4 deliberately defaults to apply_safe=False. The goal is to show
|
|
evidence and create pending suggestions for the operator, not to auto-link
|
|
fiscal customers based only on an LLM extraction.
|
|
"""
|
|
ensure_fiscal_enrichment_schema()
|
|
review = email_identity_review_for_opportunity(opportunity_id, refresh=refresh)
|
|
if not review.get("ok"):
|
|
return {"seen": 0, "suggested": 0, "auto_applied": 0, "skipped": 1, "reason": review.get("reason")}
|
|
|
|
identity = review.get("identity") or {}
|
|
opportunity = get_opportunity(opportunity_id) or {}
|
|
valid_mentions = _identity_company_mentions(identity)
|
|
identity_company = review.get("suggested_internal_customer") if valid_mentions else None
|
|
linked_customer_id = _clean(review.get("linked_customer_id"))
|
|
conflict = bool(review.get("conflict")) if valid_mentions else False
|
|
|
|
event_type = "email_identity_review"
|
|
event_note = "Identidade extraída do email para revisão fiscal assistida."
|
|
suggestion = None
|
|
|
|
if identity_company:
|
|
confidence = _confidence_for_company(identity_company, lookup_type="email_identity")
|
|
suggested_customer_id = _clean(identity_company.get("clientflow_customer_id")) or None
|
|
|
|
# v4.9.26.5 / v4.9.26.4.1:
|
|
# If the extracted identity points to the same fiscal customer already
|
|
# linked to the opportunity, do not create another pending suggestion.
|
|
# The operator needs a validation event, not duplicate work.
|
|
if suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id and not conflict:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'email_identity_matches_current_fiscal_customer', :note, CAST(:payload AS JSONB), 'email_identity_assisted_enrichment')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Identidade extraída confirma o cliente fiscal atual: {identity_company.get('nome')} / {identity_company.get('nif') or 'sem NIF'}.",
|
|
"payload": _json({"identity": identity, "customer_id": suggested_customer_id, "conflict": False}),
|
|
})
|
|
conn.execute(text("""
|
|
UPDATE opportunities
|
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
|
|
'last_email_identity_validation', jsonb_build_object(
|
|
'status', 'matches_current_fiscal_customer',
|
|
'customer_id', CAST(:customer_id AS TEXT),
|
|
'customer_name', CAST(:customer_name AS TEXT),
|
|
'customer_tax_id', CAST(:customer_tax_id AS TEXT),
|
|
'validated_at', now()
|
|
)
|
|
),
|
|
updated_at = now()
|
|
WHERE id = CAST(:opportunity_id AS UUID)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"customer_id": suggested_customer_id,
|
|
"customer_name": identity_company.get("nome"),
|
|
"customer_tax_id": identity_company.get("nif"),
|
|
})
|
|
return {
|
|
"seen": 1, "suggested": 0, "auto_applied": 0, "skipped": 1,
|
|
"identity_used": True, "conflict": False, "suggestion_id": None,
|
|
"status": "email_identity_matches_current_fiscal_customer",
|
|
}
|
|
|
|
status = "pending"
|
|
auto_applied = False
|
|
if apply_safe and not conflict and not linked_customer_id and suggested_customer_id and _should_auto_apply(opportunity, identity_company, confidence=confidence):
|
|
link_customer_to_opportunity(suggested_customer_id, opportunity_id)
|
|
status = "accepted"
|
|
auto_applied = True
|
|
suggestion = _upsert_suggestion(
|
|
opportunity_id,
|
|
identity_company,
|
|
lookup_type="email_identity",
|
|
lookup_value=", ".join(identity.get("company_mentions") or []),
|
|
confidence=confidence,
|
|
status=status,
|
|
suggested_customer_id=suggested_customer_id,
|
|
auto_applied=auto_applied,
|
|
)
|
|
event_type = "email_identity_fiscal_conflict" if conflict else "email_identity_fiscal_suggestion"
|
|
event_note = (
|
|
f"Possível conflito fiscal: email menciona {', '.join(identity.get('company_mentions') or [])}; "
|
|
f"cliente atual {review.get('linked_customer_name') or '—'}."
|
|
if conflict else
|
|
f"Sugestão fiscal por identidade extraída do email: {identity_company.get('nome')} / {identity_company.get('nif') or 'sem NIF'}."
|
|
)
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), :event_type, :note, CAST(:payload AS JSONB), 'email_identity_assisted_enrichment')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"event_type": event_type,
|
|
"note": event_note,
|
|
"payload": _json({"identity": identity, "suggestion_id": suggestion.get("id") if suggestion else None, "conflict": conflict}),
|
|
})
|
|
return {
|
|
"seen": 1, "suggested": 1, "auto_applied": 1 if auto_applied else 0, "skipped": 0,
|
|
"identity_used": True, "conflict": conflict, "suggestion_id": suggestion.get("id") if suggestion else None,
|
|
"status": event_type,
|
|
}
|
|
|
|
# No internal customer match. If a company was explicitly mentioned, ask the
|
|
# external lookup by name and create a pending suggestion, still assisted.
|
|
mentions = valid_mentions
|
|
for mention in mentions[:3]:
|
|
try:
|
|
company = _lookup_external_by_name(mention)
|
|
except Exception:
|
|
company = None
|
|
if not company:
|
|
continue
|
|
confidence = _apply_identity_confidence_guard(company, identity=identity, confidence=_confidence_for_company(company, lookup_type="name"))
|
|
if confidence < 75:
|
|
continue
|
|
suggestion = _upsert_suggestion(
|
|
opportunity_id,
|
|
company,
|
|
lookup_type="email_identity_name",
|
|
lookup_value=mention,
|
|
confidence=confidence,
|
|
status="pending",
|
|
suggested_customer_id=None,
|
|
auto_applied=False,
|
|
)
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'email_identity_external_suggestion', :note, CAST(:payload AS JSONB), 'email_identity_assisted_enrichment')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Sugestão externa por empresa extraída do email: {mention}.",
|
|
"payload": _json({"identity": identity, "suggestion_id": suggestion.get("id") if suggestion else None}),
|
|
})
|
|
return {"seen": 1, "suggested": 1, "auto_applied": 0, "skipped": 0, "identity_used": True, "suggestion_id": suggestion.get("id") if suggestion else None, "status": "email_identity_external_suggestion"}
|
|
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), :event_type, :note, CAST(:payload AS JSONB), 'email_identity_assisted_enrichment')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"event_type": event_type,
|
|
"note": event_note,
|
|
"payload": _json({"identity": identity, "conflict": conflict}),
|
|
})
|
|
return {"seen": 1, "suggested": 0, "auto_applied": 0, "skipped": 0, "identity_used": bool(identity), "status": "identity_only"}
|
|
|
|
|
|
def _apply_identity_confidence_guard(company: Dict[str, Any], *, identity: Optional[Dict[str, Any]], confidence: float) -> float:
|
|
"""Lower confidence when the endpoint only matched a domain and the email mentions another company."""
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type"))
|
|
adjusted = float(confidence or 0.0)
|
|
if match_type in DOMAIN_ONLY_MATCH_TYPES:
|
|
adjusted = min(adjusted, 85.0)
|
|
if _identity_company_conflict(identity, company):
|
|
adjusted = min(adjusted, 70.0)
|
|
company["identity_conflict"] = True
|
|
company["identity_company_mentions"] = identity.get("company_mentions") if identity else []
|
|
return adjusted
|
|
|
|
def _lookup_company_for_signal(kind: str, value: str) -> Optional[Dict[str, Any]]:
|
|
if kind == "nif":
|
|
return _lookup_external_by_nif(value)
|
|
if kind == "email":
|
|
return _lookup_external_by_email(value)
|
|
if kind == "domain":
|
|
return _lookup_external_by_domain(value)
|
|
if kind == "name":
|
|
return _lookup_external_by_name(value)
|
|
return None
|
|
|
|
|
|
def _confidence_for_company(company: Dict[str, Any], *, lookup_type: str) -> float:
|
|
score = _float(company.get("score"), 0.0)
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type"))
|
|
if match_type in VERY_STRONG_MATCH_TYPES:
|
|
return max(score, 95.0)
|
|
if match_type in STRONG_MATCH_TYPES:
|
|
return max(score, 85.0)
|
|
if match_type in MEDIUM_MATCH_TYPES:
|
|
return max(score, 65.0)
|
|
if lookup_type == "nif" and normalize_tax_id(company.get("nif")):
|
|
return max(score, 100.0)
|
|
if score:
|
|
return score
|
|
return 50.0 if lookup_type == "name" else 70.0
|
|
|
|
|
|
def _reason_for_company(company: Dict[str, Any], *, lookup_type: str, lookup_value: str) -> str:
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type") or "match")
|
|
name = _clean(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
nif = normalize_tax_id(company.get("nif") or company.get("tax_id"))
|
|
return f"{match_type} por {lookup_type}={lookup_value}; empresa={name}; nif={nif or '—'}"
|
|
|
|
|
|
def _upsert_suggestion(
|
|
opportunity_id: str,
|
|
company: Dict[str, Any],
|
|
*,
|
|
lookup_type: str,
|
|
lookup_value: str,
|
|
confidence: float,
|
|
status: str = "pending",
|
|
suggested_customer_id: Optional[str] = None,
|
|
auto_applied: bool = False,
|
|
) -> Dict[str, Any]:
|
|
ensure_fiscal_enrichment_schema()
|
|
name = _clean(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
nif = normalize_tax_id(company.get("nif") or company.get("tax_id"))
|
|
reason = _reason_for_company(company, lookup_type=lookup_type, lookup_value=lookup_value)
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type"))
|
|
params = {
|
|
"opportunity_id": opportunity_id,
|
|
"suggested_customer_id": suggested_customer_id,
|
|
"suggested_nif": nif or None,
|
|
"suggested_name": name,
|
|
"source": CACHE_SOURCE,
|
|
"lookup_type": lookup_type,
|
|
"lookup_value": lookup_value,
|
|
"match_type": match_type,
|
|
"confidence": confidence,
|
|
"reason": reason,
|
|
"status": status,
|
|
"auto_applied": auto_applied,
|
|
"raw_payload": _json(company),
|
|
}
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
INSERT INTO fiscal_customer_suggestions (
|
|
opportunity_id, suggested_customer_id, suggested_nif, suggested_name, source,
|
|
lookup_type, lookup_value, match_type, confidence, reason, status, auto_applied,
|
|
raw_payload, updated_at, resolved_at, resolved_by
|
|
) VALUES (
|
|
CAST(:opportunity_id AS UUID), CAST(:suggested_customer_id AS UUID), :suggested_nif, :suggested_name, :source,
|
|
:lookup_type, :lookup_value, :match_type, :confidence, :reason, :status, :auto_applied,
|
|
CAST(:raw_payload AS JSONB), now(), CASE WHEN :status <> 'pending' THEN now() ELSE NULL END,
|
|
CASE WHEN :status <> 'pending' THEN 'fiscal_enrichment_worker' ELSE NULL END
|
|
)
|
|
ON CONFLICT (opportunity_id, suggested_nif)
|
|
WHERE suggested_nif IS NOT NULL AND suggested_nif <> '' AND status = 'pending'
|
|
DO UPDATE SET
|
|
suggested_customer_id = COALESCE(EXCLUDED.suggested_customer_id, fiscal_customer_suggestions.suggested_customer_id),
|
|
suggested_name = EXCLUDED.suggested_name,
|
|
lookup_type = EXCLUDED.lookup_type,
|
|
lookup_value = EXCLUDED.lookup_value,
|
|
match_type = EXCLUDED.match_type,
|
|
confidence = GREATEST(EXCLUDED.confidence, fiscal_customer_suggestions.confidence),
|
|
reason = EXCLUDED.reason,
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
updated_at = now()
|
|
RETURNING id::text, opportunity_id::text, suggested_customer_id::text, suggested_nif,
|
|
suggested_name, source, lookup_type, lookup_value, match_type, confidence,
|
|
reason, status, auto_applied, raw_payload, created_at, updated_at, resolved_at
|
|
"""), params).mappings().first()
|
|
return dict(row or {})
|
|
|
|
|
|
def _has_conflicting_customer(opportunity: Dict[str, Any], suggested_tax_id: str) -> bool:
|
|
linked_tax_id = normalize_tax_id(opportunity.get("linked_customer_tax_id"))
|
|
return bool(linked_tax_id and suggested_tax_id and linked_tax_id != suggested_tax_id)
|
|
|
|
|
|
def _should_auto_apply(opportunity: Dict[str, Any], company: Dict[str, Any], *, confidence: float) -> bool:
|
|
if opportunity.get("linked_customer_id") or opportunity.get("local_customer_id"):
|
|
return False
|
|
tax_id = normalize_tax_id(company.get("nif") or company.get("tax_id"))
|
|
name = _clean(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
if not (tax_id and name):
|
|
return False
|
|
if _has_conflicting_customer(opportunity, tax_id):
|
|
return False
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type"))
|
|
if match_type in DOMAIN_ONLY_MATCH_TYPES:
|
|
return False
|
|
return confidence >= _auto_threshold() and match_type in VERY_STRONG_MATCH_TYPES
|
|
|
|
|
|
def apply_fiscal_suggestion(suggestion_id: str, *, actor: str = "operator") -> Dict[str, Any]:
|
|
"""Accept a suggestion and link/create the fiscal customer idempotently."""
|
|
ensure_fiscal_enrichment_schema()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT * FROM fiscal_customer_suggestions
|
|
WHERE id = CAST(:id AS UUID)
|
|
LIMIT 1
|
|
"""), {"id": suggestion_id}).mappings().first()
|
|
if not row:
|
|
return {"applied": False, "reason": "suggestion_not_found"}
|
|
suggestion = dict(row)
|
|
company = suggestion.get("raw_payload") if isinstance(suggestion.get("raw_payload"), dict) else {}
|
|
opportunity_id = str(suggestion.get("opportunity_id") or "")
|
|
opportunity = get_opportunity(opportunity_id)
|
|
if not opportunity:
|
|
return {"applied": False, "reason": "opportunity_not_found"}
|
|
if opportunity.get("linked_customer_id") and normalize_tax_id(opportunity.get("linked_customer_tax_id")) != normalize_tax_id(suggestion.get("suggested_nif")):
|
|
return {"applied": False, "reason": "opportunity_has_conflicting_customer"}
|
|
customer = upsert_customer(_company_to_customer_data(company))
|
|
customer_id = str(customer.get("id") or "")
|
|
if not customer_id:
|
|
return {"applied": False, "reason": "customer_upsert_failed"}
|
|
link_customer_to_opportunity(customer_id, opportunity_id)
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE fiscal_customer_suggestions
|
|
SET status = 'accepted', suggested_customer_id = CAST(:customer_id AS UUID),
|
|
auto_applied = COALESCE(auto_applied, FALSE), resolved_at = now(), resolved_by = :actor,
|
|
updated_at = now()
|
|
WHERE id = CAST(:id AS UUID)
|
|
"""), {"id": suggestion_id, "customer_id": customer_id, "actor": actor})
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'fiscal_enrichment_applied', :note, CAST(:payload AS JSONB), :actor)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Cliente fiscal associado por enriquecimento: {customer.get('name')} / {customer.get('tax_id') or 'sem NIF'}",
|
|
"payload": _json({"suggestion_id": suggestion_id, "customer_id": customer_id, "source": CACHE_SOURCE}),
|
|
"actor": actor,
|
|
})
|
|
return {"applied": True, "customer_id": customer_id, "opportunity_id": opportunity_id}
|
|
|
|
|
|
def reject_fiscal_suggestion(suggestion_id: str, *, actor: str = "operator", reason: str = "rejected_by_operator") -> Dict[str, Any]:
|
|
ensure_fiscal_enrichment_schema()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
UPDATE fiscal_customer_suggestions
|
|
SET status = 'rejected', reason = COALESCE(reason, '') || ' | ' || :reason,
|
|
resolved_at = now(), resolved_by = :actor, updated_at = now()
|
|
WHERE id = CAST(:id AS UUID)
|
|
RETURNING id::text
|
|
"""), {"id": suggestion_id, "actor": actor, "reason": reason}).mappings().first()
|
|
return {"rejected": bool(row)}
|
|
|
|
|
|
def list_fiscal_suggestions_for_opportunity(opportunity_id: str, *, limit: int = 5) -> List[Dict[str, Any]]:
|
|
ensure_fiscal_enrichment_schema()
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(text("""
|
|
SELECT
|
|
s.id::text,
|
|
s.opportunity_id::text,
|
|
s.suggested_customer_id::text,
|
|
COALESCE(NULLIF(s.suggested_nif, ''), c.tax_id) AS suggested_nif,
|
|
COALESCE(NULLIF(s.suggested_name, ''), c.name) AS suggested_name,
|
|
s.source,
|
|
s.lookup_type,
|
|
s.lookup_value,
|
|
s.match_type,
|
|
s.confidence,
|
|
s.reason,
|
|
s.status,
|
|
s.auto_applied,
|
|
s.created_at,
|
|
s.updated_at,
|
|
s.resolved_at
|
|
FROM fiscal_customer_suggestions s
|
|
LEFT JOIN customers c ON c.id = s.suggested_customer_id
|
|
WHERE s.opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY CASE s.status WHEN 'pending' THEN 0 WHEN 'accepted' THEN 1 ELSE 2 END,
|
|
s.confidence DESC, s.updated_at DESC
|
|
LIMIT :limit
|
|
"""), {"opportunity_id": opportunity_id, "limit": int(limit)}).mappings().all()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def enrich_opportunity(opportunity_id: str, *, apply_safe: bool = True) -> Dict[str, Any]:
|
|
"""Enrich a single opportunity with a fiscal customer suggestion/association."""
|
|
ensure_fiscal_enrichment_schema()
|
|
opportunity = get_opportunity(opportunity_id)
|
|
if not opportunity:
|
|
return {"seen": 0, "enriched": 0, "suggested": 0, "auto_applied": 0, "skipped": 1, "reason": "opportunity_not_found"}
|
|
if opportunity.get("linked_customer_id") or opportunity.get("local_customer_id"):
|
|
return {"seen": 1, "enriched": 0, "suggested": 0, "auto_applied": 0, "skipped": 1, "reason": "already_has_fiscal_customer"}
|
|
|
|
errors: List[str] = []
|
|
identity: Optional[Dict[str, Any]] = None
|
|
if bool(getattr(settings, "email_identity_extraction_enabled", True)):
|
|
try:
|
|
identity = extract_identity_for_opportunity(
|
|
opportunity_id,
|
|
refresh=False,
|
|
use_llm=bool(getattr(settings, "email_identity_extraction_use_llm", True)),
|
|
)
|
|
except Exception as exc:
|
|
errors.append(f"email_identity_extraction_failed: {exc}")
|
|
identity = None
|
|
|
|
identity_company = _find_internal_customer_by_identity(identity, opportunity)
|
|
if identity_company:
|
|
confidence = _confidence_for_company(identity_company, lookup_type="email_identity")
|
|
existing_customer_id = _clean(identity_company.get("clientflow_customer_id")) or None
|
|
auto_applied = False
|
|
status = "pending"
|
|
if apply_safe and existing_customer_id and _should_auto_apply(opportunity, identity_company, confidence=confidence):
|
|
link_customer_to_opportunity(existing_customer_id, opportunity_id)
|
|
status = "accepted"
|
|
auto_applied = True
|
|
suggestion = _upsert_suggestion(
|
|
opportunity_id,
|
|
identity_company,
|
|
lookup_type="email_identity",
|
|
lookup_value=", ".join(identity.get("company_mentions") or []) if identity else "",
|
|
confidence=confidence,
|
|
status=status,
|
|
suggested_customer_id=existing_customer_id,
|
|
auto_applied=auto_applied,
|
|
)
|
|
if auto_applied:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'email_identity_fiscal_auto_applied', :note, CAST(:payload AS JSONB), 'email_identity_extraction_service')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Cliente fiscal auto-associado por empresa explícita no email: {identity_company.get('nome')} / {identity_company.get('nif') or 'sem NIF'}",
|
|
"payload": _json({"suggestion_id": suggestion.get("id"), "identity": identity, "confidence": confidence}),
|
|
})
|
|
return {"seen": 1, "enriched": 1, "suggested": 1, "auto_applied": 1, "skipped": 0, "customer_id": existing_customer_id, "suggestion_id": suggestion.get("id"), "identity_used": True}
|
|
return {"seen": 1, "enriched": 0, "suggested": 1, "auto_applied": 0, "skipped": 0, "suggestion_id": suggestion.get("id"), "confidence": confidence, "errors": errors, "identity_used": True}
|
|
|
|
for lookup_type, lookup_value in _source_signals_from_opportunity(opportunity):
|
|
try:
|
|
company = _lookup_company_for_signal(lookup_type, lookup_value)
|
|
except Exception as exc: # keep worker resilient
|
|
errors.append(str(exc))
|
|
continue
|
|
if not company:
|
|
continue
|
|
name = _clean(company.get("nome") or company.get("legal_name") or company.get("name"))
|
|
tax_id = normalize_tax_id(company.get("nif") or company.get("tax_id"))
|
|
if not name:
|
|
continue
|
|
confidence = _confidence_for_company(company, lookup_type=lookup_type)
|
|
confidence = _apply_identity_confidence_guard(company, identity=identity, confidence=confidence)
|
|
match_type = _clean(company.get("match_type") or company.get("empresa_resolution_type"))
|
|
# v4.9.25.1: keep the enrichment queue operationally clean.
|
|
# Fuzzy/name-only matches below 75 or explicit approximate-name matches
|
|
# are too noisy for the normal workflow and should not create pending
|
|
# suggestions. They can still be inspected by querying the external
|
|
# source directly when needed.
|
|
if match_type == "nome_aproximado" or confidence < 75.0:
|
|
continue
|
|
existing_customer_id: Optional[str] = None
|
|
if tax_id:
|
|
existing = get_customer_by_tax_id(tax_id)
|
|
if existing:
|
|
existing_customer_id = str(existing.get("id") or "") or None
|
|
auto_applied = False
|
|
status = "pending"
|
|
if apply_safe and _should_auto_apply(opportunity, company, confidence=confidence):
|
|
customer = upsert_customer(_company_to_customer_data(company))
|
|
existing_customer_id = str(customer.get("id") or "") or existing_customer_id
|
|
if existing_customer_id:
|
|
link_customer_to_opportunity(existing_customer_id, opportunity_id)
|
|
status = "accepted"
|
|
auto_applied = True
|
|
suggestion = _upsert_suggestion(
|
|
opportunity_id,
|
|
company,
|
|
lookup_type=lookup_type,
|
|
lookup_value=lookup_value,
|
|
confidence=confidence,
|
|
status=status,
|
|
suggested_customer_id=existing_customer_id,
|
|
auto_applied=auto_applied,
|
|
)
|
|
if auto_applied:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
|
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'fiscal_enrichment_auto_applied', :note, CAST(:payload AS JSONB), 'fiscal_enrichment_worker')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Cliente fiscal auto-associado: {name} / {tax_id}",
|
|
"payload": _json({"suggestion_id": suggestion.get("id"), "lookup_type": lookup_type, "lookup_value": lookup_value, "confidence": confidence}),
|
|
})
|
|
return {"seen": 1, "enriched": 1, "suggested": 1, "auto_applied": 1, "skipped": 0, "customer_id": existing_customer_id, "suggestion_id": suggestion.get("id")}
|
|
return {"seen": 1, "enriched": 0, "suggested": 1, "auto_applied": 0, "skipped": 0, "suggestion_id": suggestion.get("id"), "confidence": confidence, "errors": errors}
|
|
|
|
return {"seen": 1, "enriched": 0, "suggested": 0, "auto_applied": 0, "skipped": 1, "reason": "no_company_match", "errors": errors}
|
|
|
|
|
|
def _open_opportunities_without_fiscal_customer(*, limit: int = 100) -> List[Dict[str, Any]]:
|
|
# list_opportunities carries linked_customer_id and contact fields and uses
|
|
# the same query path as the UI. Filter in Python to avoid duplicating a
|
|
# large SQL projection here.
|
|
opportunities = list_opportunities(status="open", limit=max(int(limit or 100), 1))
|
|
return [o for o in opportunities if not (o.get("linked_customer_id") or o.get("local_customer_id"))]
|
|
|
|
|
|
def enrich_open_opportunities(*, limit: int = 100, apply_safe: bool = True, mode: str = "incremental") -> Dict[str, Any]:
|
|
"""Worker entrypoint: enrich open opportunities missing fiscal customer."""
|
|
ensure_fiscal_enrichment_schema()
|
|
started = time.time()
|
|
seen = enriched = suggested = auto_applied = skipped = 0
|
|
errors: List[str] = []
|
|
opportunities = _open_opportunities_without_fiscal_customer(limit=limit)
|
|
for opportunity in opportunities[: int(limit or 100)]:
|
|
result = enrich_opportunity(str(opportunity.get("id")), apply_safe=apply_safe)
|
|
seen += int(result.get("seen") or 0)
|
|
enriched += int(result.get("enriched") or 0)
|
|
suggested += int(result.get("suggested") or 0)
|
|
auto_applied += int(result.get("auto_applied") or 0)
|
|
skipped += int(result.get("skipped") or 0)
|
|
errors.extend(str(e) for e in (result.get("errors") or []) if e)
|
|
summary = {
|
|
"mode": mode,
|
|
"enabled": _external_enabled(),
|
|
"seen": seen,
|
|
"enriched": enriched,
|
|
"suggested": suggested,
|
|
"auto_applied": auto_applied,
|
|
"skipped": skipped,
|
|
"errors": errors[:10],
|
|
"duration_seconds": round(time.time() - started, 3),
|
|
}
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO fiscal_enrichment_runs (mode, status, seen, enriched, suggested, auto_applied, skipped, errors, payload, finished_at)
|
|
VALUES (:mode, 'finished', :seen, :enriched, :suggested, :auto_applied, :skipped, CAST(:errors AS JSONB), CAST(:payload AS JSONB), now())
|
|
"""), {
|
|
"mode": mode,
|
|
"seen": seen,
|
|
"enriched": enriched,
|
|
"suggested": suggested,
|
|
"auto_applied": auto_applied,
|
|
"skipped": skipped,
|
|
"errors": _json(errors[:10]),
|
|
"payload": _json(summary),
|
|
})
|
|
return summary
|
|
|
|
|
|
def fiscal_enrichment_summary() -> Dict[str, Any]:
|
|
ensure_fiscal_enrichment_schema()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM opportunities WHERE status = 'open' AND local_customer_id IS NULL)::int AS open_without_fiscal_customer,
|
|
(SELECT COUNT(*) FROM fiscal_customer_suggestions WHERE status = 'pending')::int AS pending_suggestions,
|
|
(SELECT COUNT(*) FROM fiscal_customer_suggestions WHERE status = 'accepted' AND auto_applied = TRUE)::int AS auto_applied_suggestions,
|
|
(SELECT MAX(finished_at) FROM fiscal_enrichment_runs)::text AS last_run_at
|
|
""")).mappings().first()
|
|
return dict(row or {})
|