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

@@ -53,6 +53,27 @@ def normalize_fiscal_name(value: Any) -> str:
return re.sub(r"\s+", " ", name).strip()
def normalize_email(value: Any) -> str:
"""Normaliza email para identidade lógica do cliente.
A criação de clientes deve tratar ``CLIENTE@EXEMPLO.COM`` e
`` cliente@exemplo.com `` como a mesma ficha comercial.
"""
email = _clean(value).casefold()
email = re.sub(r"\s+", "", email)
return email
def _validate_email_or_empty(value: Any) -> str:
email = normalize_email(value)
if not email:
return ""
# Validação deliberadamente simples: evita lixo óbvio sem bloquear emails válidos raros.
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise ValueError("Email inválido.")
return email
def ensure_commercial_schema() -> None:
global _SCHEMA_READY
if _SCHEMA_READY:
@@ -83,6 +104,7 @@ def ensure_commercial_schema() -> None:
WHERE tax_id IS NOT NULL AND tax_id <> ''
"""))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_email_normalized ON customers((lower(trim(email)))) WHERE email IS NOT NULL AND trim(email) <> ''"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_jasmin_key ON customers(jasmin_customer_party_key)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_external_identity ON customers USING gin (metadata)"))
@@ -327,7 +349,7 @@ def upsert_customer(data: Dict[str, Any]) -> Dict[str, Any]:
params = {
"name": name,
"tax_id": tax_id or None,
"email": _clean(data.get("email") or data.get("electronicMail")) or None,
"email": _validate_email_or_empty(data.get("email") or data.get("electronicMail")) or None,
"phone": _clean(data.get("phone") or data.get("telephone")) or None,
"street_name": _clean(data.get("street_name") or data.get("streetName")) or None,
"postal_zone": _clean(data.get("postal_zone") or data.get("postalZone")) or None,
@@ -338,6 +360,37 @@ def upsert_customer(data: Dict[str, Any]) -> Dict[str, Any]:
"metadata": _json(data.get("metadata") or {}),
}
with engine.begin() as conn:
# Identidade forte por email quando não há NIF. Evita fichas duplicadas
# por maiúsculas/espaços no email. O NIF continua a ter prioridade.
if not params["tax_id"] and params["email"]:
existing_by_email = conn.execute(text("""
SELECT id::text, name, tax_id, email, phone, street_name, postal_zone,
city_name, country, jasmin_customer_party_key, jasmin_customer_id,
metadata, created_at, updated_at
FROM customers
WHERE lower(trim(email)) = :email
ORDER BY updated_at DESC
LIMIT 1
"""), {"email": params["email"]}).mappings().first()
if existing_by_email:
row = conn.execute(text("""
UPDATE customers
SET name = COALESCE(NULLIF(:name, ''), name),
phone = COALESCE(:phone, phone),
street_name = COALESCE(:street_name, street_name),
postal_zone = COALESCE(:postal_zone, postal_zone),
city_name = COALESCE(:city_name, city_name),
country = COALESCE(:country, country),
jasmin_customer_party_key = COALESCE(:jasmin_customer_party_key, jasmin_customer_party_key),
jasmin_customer_id = COALESCE(:jasmin_customer_id, jasmin_customer_id),
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE id = CAST(:id AS UUID)
RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone,
city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at
"""), {**params, "id": existing_by_email["id"]}).mappings().first()
return dict(row or {})
if params["tax_id"]:
row = conn.execute(text("""
INSERT INTO customers (
@@ -758,7 +811,7 @@ def update_customer(customer_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
"id": customer_id,
"name": _clean(data.get("name")),
"tax_id": normalize_tax_id(data.get("tax_id")),
"email": _clean(data.get("email")) or None,
"email": _validate_email_or_empty(data.get("email")) or None,
"phone": _clean(data.get("phone")) or None,
"street_name": _clean(data.get("street_name")) or None,
"postal_zone": _clean(data.get("postal_zone")) or None,