Files
4ventures-client-portal/scripts/sync_clientify_tenant.py
4Ventures 062c43325d fix: ventas KPI = Online Clientify (89) y atribución por fuente real
Excluye Málaga del cohort; enriquece source desde lead/Clientify para que el cruce ads vs CRM deje de volcar todo en sin-atribuir.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:19:57 +02:00

344 lines
12 KiB
Python

#!/usr/bin/env python3
"""Sync Clientify contacts → leads and deals → deals for one tenant.
Requires in .env.local:
CLIENTIFY_API_KEY (or CLIENTIFY_API_KEY_<SLUG>)
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY
Usage:
python3 scripts/sync_clientify_tenant.py --tenant-id a7a6eb49-... --label agua-miia
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CFY = "https://api.clientify.com/v1"
# Clientify deal status_desc → warehouse stage
STAGE_MAP = {
"won": "won",
"lost": "lost",
"open": "open",
}
def load_env() -> None:
for line in (ROOT / ".env.local").read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
k = k.strip()
if k and k not in os.environ:
os.environ[k] = v.strip().strip('"').strip("'")
def env_key(label: str) -> str:
slug = label.upper().replace("-", "_").replace(" ", "_")
return (
os.environ.get(f"CLIENTIFY_API_KEY_{slug}")
or os.environ.get("CLIENTIFY_API_KEY")
or ""
)
def sb_headers() -> dict[str, str]:
key = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
return {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
def sb(method: str, path: str, body: Any = None, prefer: str = "return=minimal"):
url = os.environ["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") + path
headers = dict(sb_headers())
if prefer:
headers["Prefer"] = prefer
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw.strip() else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:500]
def cfy_get(token: str, path: str) -> dict:
req = urllib.request.Request(
CFY + path,
headers={"Authorization": f"Token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
def cfy_paginate(token: str, path: str, page_size: int = 100) -> list[dict]:
out: list[dict] = []
# path may already have query
sep = "&" if "?" in path else "?"
url_path = f"{path}{sep}page_size={page_size}"
page = 1
while True:
q = f"{url_path}&page={page}" if "page=" not in url_path else url_path
# rebuild clean
base, _, query = path.partition("?")
params = urllib.parse.parse_qs(query)
params["page_size"] = [str(page_size)]
params["page"] = [str(page)]
q = base + "?" + urllib.parse.urlencode({k: v[0] for k, v in params.items()})
j = cfy_get(token, q)
batch = j.get("results") or []
out.extend(batch)
print(f" {path} page={page} +{len(batch)} total={len(out)}/{j.get('count')}", flush=True)
if not j.get("next") or not batch:
break
page += 1
return out
def first_email(c: dict) -> str | None:
emails = c.get("emails") or []
if isinstance(emails, list) and emails:
e = emails[0]
if isinstance(e, dict):
return (e.get("email") or e.get("value") or "").strip() or None
return str(e).strip() or None
return None
def first_phone(c: dict) -> str | None:
phones = c.get("phones") or []
if isinstance(phones, list) and phones:
p = phones[0]
if isinstance(p, dict):
return (p.get("phone") or p.get("value") or "").strip() or None
return str(p).strip() or None
return None
def contact_id_from_url(url: str | None) -> str | None:
if not url:
return None
# https://api.clientify.com/v1/contacts/12345/
parts = url.rstrip("/").split("/")
return parts[-1] if parts else None
def map_stage(deal: dict) -> str:
desc = (deal.get("status_desc") or "").strip().lower()
if desc in STAGE_MAP:
return STAGE_MAP[desc]
st = deal.get("status")
# Observed: 1 open-ish, 3 won, 4 lost (API filter unreliable)
if st == 3 or desc == "won":
return "won"
if st == 4 or desc == "lost":
return "lost"
return "open"
def parse_date(s: str | None) -> str | None:
if not s:
return None
# date-only or iso
try:
if "T" in s:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat()
return datetime.fromisoformat(s + "T12:00:00+00:00").astimezone(timezone.utc).isoformat()
except Exception:
return None
def main() -> int:
load_env()
ap = argparse.ArgumentParser()
ap.add_argument("--tenant-id", required=True)
ap.add_argument("--label", default="tenant")
ap.add_argument("--contacts-only", action="store_true")
ap.add_argument("--deals-only", action="store_true")
args = ap.parse_args()
token = env_key(args.label)
if not token:
print("missing CLIENTIFY_API_KEY", file=sys.stderr)
return 2
tid = args.tenant_id
print(f"Sync Clientify → warehouse tenant={tid} label={args.label}", flush=True)
do_contacts = not args.deals_only
do_deals = not args.contacts_only
contact_to_lead: dict[str, str] = {}
if do_contacts:
print("Fetching contacts…", flush=True)
contacts = cfy_paginate(token, "/contacts/")
# wipe previous clientify leads for tenant
st, _ = sb(
"DELETE",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify",
)
print(f"DELETE leads → {st}", flush=True)
rows = []
for c in contacts:
cid = str(c["id"])
lid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-lead:{tid}:{cid}"))
contact_to_lead[cid] = lid
source = (
(c.get("contact_source") or c.get("channel") or c.get("medium") or "clientify")
)
if isinstance(source, str):
source = source.strip().lower()[:80] or "clientify"
else:
source = "clientify"
status = (c.get("status") or "new")
if isinstance(status, str):
status = status.strip().lower()[:40] or "new"
else:
status = "new"
rows.append(
{
"id": lid,
"tenant_id": tid,
"first_name": (
" ".join(
x
for x in [(c.get("first_name") or "").strip(), (c.get("last_name") or "").strip()]
if x
)
or None
),
"email": first_email(c),
"phone": first_phone(c),
"source": source,
"medium": (c.get("medium") or None),
"crm": "clientify",
"crm_contact_id": cid,
"status": status,
"created_at": parse_date(c.get("created"))
or datetime.now(timezone.utc).isoformat(),
}
)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/leads", chunk)
if st not in (200, 201):
print("FAIL leads", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT leads {ok}/{len(rows)}", flush=True)
print(f"OK contacts→leads {ok}", flush=True)
if do_deals:
if not contact_to_lead and do_contacts is False:
# load existing map
st, data = sb(
"GET",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify&select=id,crm_contact_id&limit=20000",
prefer="",
)
if st == 200 and isinstance(data, list):
for r in data:
if r.get("crm_contact_id"):
contact_to_lead[str(r["crm_contact_id"])] = r["id"]
print("Fetching deals…", flush=True)
deals = cfy_paginate(token, "/deals/")
st, _ = sb(
"DELETE",
f"/rest/v1/deals?tenant_id=eq.{tid}&crm_deal_id=like.clientify:*",
)
print(f"DELETE deals → {st}", flush=True)
rows = []
by_stage = {"won": 0, "lost": 0, "open": 0}
for d in deals:
did = str(d["id"])
stage = map_stage(d)
by_stage[stage] = by_stage.get(stage, 0) + 1
cid = contact_id_from_url(d.get("contact"))
lead_id = contact_to_lead.get(cid) if cid else None
amount = float(d.get("amount") or d.get("amount_user") or 0)
closed = parse_date(d.get("actual_closed_date"))
pipe_url = str(d.get("pipeline") or "")
pipe_id = ""
if "/pipelines/" in pipe_url:
pipe_id = pipe_url.rstrip("/").split("/")[-1]
raw_src = (
d.get("deal_source")
or d.get("contact_source")
or ""
)
if isinstance(raw_src, str):
raw_src = raw_src.strip()[:80]
else:
raw_src = ""
tags = d.get("tags") or []
tag_blob = " ".join(str(t) for t in tags).lower() if isinstance(tags, list) else ""
name = (d.get("name") or "")[:200]
if not raw_src:
if any(x in tag_blob for x in ("facebook", "instagram", "meta", " fb ")):
raw_src = "meta"
elif "google" in tag_blob:
raw_src = "google"
elif "woocommerce" in tag_blob or name.lower().startswith("order "):
raw_src = "ecommerce"
row = {
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")),
"tenant_id": tid,
"lead_id": lead_id,
"crm_deal_id": f"clientify:{did}",
"stage": stage,
"amount": amount,
"won_at": closed if stage == "won" else None,
"lost_at": closed if stage == "lost" else None,
"pipeline_id": pipe_id or None,
"pipeline_name": (d.get("pipeline_desc") or None),
"source": raw_src.lower() if raw_src else None,
"name": name or None,
}
rows.append(row)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/deals", chunk)
if st not in (200, 201):
print("FAIL deals", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT deals {ok}/{len(rows)}", flush=True)
won_amt = sum(r["amount"] for r in rows if r["stage"] == "won")
print(f"OK deals {ok} by_stage={by_stage} won_amount={won_amt:.2f}", flush=True)
# touch integration
now = datetime.now(timezone.utc).isoformat()
sb(
"PATCH",
f"/rest/v1/integrations?tenant_id=eq.{tid}&provider=eq.clientify",
{"status": "connected", "last_sync_at": now, "error_message": None},
)
print("DONE", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())