feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
75
scripts/sync_ads_only.ts
Normal file
75
scripts/sync_ads_only.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Sync Meta + Google Ads → ad_daily (+ kpi_weekly rollup).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/sync_ads_only.ts # last 30d all tenants
|
||||
* DAYS=640 npx tsx scripts/sync_ads_only.ts # backfill ~from Jan 2025
|
||||
* TENANT_SLUG=bps DAYS=640 npx tsx ...
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
syncAdsForTenants,
|
||||
} from '../src/lib/ads-sync';
|
||||
|
||||
async function main() {
|
||||
const slug = process.env.TENANT_SLUG?.trim();
|
||||
let tenantId: string | undefined;
|
||||
if (slug) {
|
||||
const { serviceHeaders, supabaseUrl } = await import('../src/lib/agency');
|
||||
const headers = serviceHeaders()!;
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||
{ headers }
|
||||
);
|
||||
const rows = (await r.json()) as { id: string }[];
|
||||
tenantId = rows[0]?.id;
|
||||
if (!tenantId) throw new Error(`Tenant slug not found: ${slug}`);
|
||||
}
|
||||
|
||||
// Days since 2025-01-01 (inclusive) when DAYS unset and BACKFILL=1
|
||||
const backfill = process.env.BACKFILL === '1' || process.env.BACKFILL === 'true';
|
||||
const daysFrom2025 = Math.ceil(
|
||||
(Date.now() - Date.UTC(2025, 0, 1)) / 86400000
|
||||
);
|
||||
const rawDays = Number(process.env.DAYS);
|
||||
const days = Math.min(
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
Math.max(
|
||||
1,
|
||||
Number.isFinite(rawDays) && rawDays > 0
|
||||
? rawDays
|
||||
: backfill
|
||||
? daysFrom2025
|
||||
: 30
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Ads sync · days=${days} · since≈${new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)}` +
|
||||
(tenantId ? ` · tenant=${slug || tenantId}` : ' · all tenants')
|
||||
);
|
||||
|
||||
const { results } = await syncAdsForTenants({ tenantId, days });
|
||||
let ok = 0;
|
||||
let err = 0;
|
||||
for (const x of results) {
|
||||
const rows = x.metaRows + x.googleRows;
|
||||
if (rows > 0) ok += 1;
|
||||
if (x.errors.length) err += 1;
|
||||
console.log(
|
||||
`${x.tenantName}: meta=${x.metaRows} google=${x.googleRows} weekly=${x.weeklyRows}` +
|
||||
(x.errors.length
|
||||
? ` · ERR ${x.errors.join('; ').slice(0, 200)}`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
console.log(`\nDone · synced=${ok} withErrors=${err} / ${results.length}`);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
272
scripts/sync_all_data.ts
Normal file
272
scripts/sync_all_data.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* One-shot sync: organic + ads + superhuman + SEO audits.
|
||||
* Usage:
|
||||
* npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts
|
||||
* Optional: TENANT_SLUG=bps
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||
import { syncAllConnectedOrganic } from '../src/lib/google-organic';
|
||||
import { syncAdsForTenants } from '../src/lib/ads-sync';
|
||||
import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs';
|
||||
import { runSeoSiteAudit } from '../src/lib/seo-audit';
|
||||
import { resolveMetaAccessTokens } from '../src/lib/ads-discovery';
|
||||
|
||||
const META = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
type Tenant = { id: string; name: string; slug: string };
|
||||
|
||||
async function sbGet<T>(path: string): Promise<T> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`);
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
async function sbPatch(path: string, body: unknown) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
async function sbPost(path: string, body: unknown) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`);
|
||||
}
|
||||
|
||||
async function syncSocial(tenants: Tenant[]) {
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
const token = tokens[0];
|
||||
if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] };
|
||||
|
||||
const results: { tenant: string; fb?: string; ig?: string }[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const integs = await sbGet<
|
||||
{
|
||||
provider: string;
|
||||
external_account_id?: string | null;
|
||||
status: string;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}[]
|
||||
>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta`
|
||||
);
|
||||
if (!integs.length) continue;
|
||||
const out: { tenant: string; fb?: string; ig?: string } = {
|
||||
tenant: tenant.name,
|
||||
};
|
||||
|
||||
for (const i of integs) {
|
||||
const id = i.external_account_id;
|
||||
if (!id) continue;
|
||||
try {
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
if (i.provider === 'facebook') {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
payload = (await r.json()) as Record<string, unknown>;
|
||||
out.fb = 'ok';
|
||||
} else {
|
||||
out.fb = `err ${(await r.text()).slice(0, 80)}`;
|
||||
}
|
||||
} else {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
payload = (await r.json()) as Record<string, unknown>;
|
||||
out.ig = 'ok';
|
||||
} else {
|
||||
out.ig = `err ${(await r.text()).slice(0, 80)}`;
|
||||
}
|
||||
}
|
||||
if (payload) {
|
||||
const now = new Date().toISOString();
|
||||
await sbPatch(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`,
|
||||
{
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
meta: {
|
||||
...(i.meta || {}),
|
||||
social_cache: payload,
|
||||
social_cached_at: now,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'err';
|
||||
if (i.provider === 'facebook') out.fb = msg;
|
||||
else out.ig = msg;
|
||||
}
|
||||
}
|
||||
results.push(out);
|
||||
}
|
||||
return { ok: true as const, results };
|
||||
}
|
||||
|
||||
async function syncSeoAudits(tenants: Tenant[]) {
|
||||
const results: {
|
||||
tenant: string;
|
||||
score?: number;
|
||||
grade?: string;
|
||||
error?: string;
|
||||
}[] = [];
|
||||
for (const tenant of tenants) {
|
||||
const [gsc, ga4] = await Promise.all([
|
||||
sbGet<
|
||||
{ external_account_id?: string | null; meta?: { site_url?: string } }[]
|
||||
>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1`
|
||||
),
|
||||
sbGet<{ id: string }[]>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1`
|
||||
),
|
||||
]);
|
||||
const site =
|
||||
gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null;
|
||||
if (!site) continue;
|
||||
try {
|
||||
const audit = await runSeoSiteAudit({
|
||||
gscSiteUrl: site,
|
||||
ga4Connected: ga4.length > 0,
|
||||
gscConnected: true,
|
||||
});
|
||||
await sbPost('seo_audits', {
|
||||
tenant_id: tenant.id,
|
||||
site_url: audit.siteUrl,
|
||||
score: audit.score,
|
||||
grade: audit.grade,
|
||||
warning_count: audit.warningCount,
|
||||
critical_count: audit.criticalCount,
|
||||
checks: audit.checks,
|
||||
summary: audit.summary,
|
||||
});
|
||||
results.push({
|
||||
tenant: tenant.name,
|
||||
score: audit.score,
|
||||
grade: audit.grade,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
tenant: tenant.name,
|
||||
error: e instanceof Error ? e.message.slice(0, 160) : 'audit error',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY');
|
||||
|
||||
let tenants = await sbGet<Tenant[]>(
|
||||
'tenants?select=id,name,slug&order=name.asc'
|
||||
);
|
||||
const slug = process.env.TENANT_SLUG?.trim();
|
||||
const tid = process.env.TENANT_ID?.trim();
|
||||
if (slug) tenants = tenants.filter((t) => t.slug === slug);
|
||||
if (tid) tenants = tenants.filter((t) => t.id === tid);
|
||||
|
||||
console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`);
|
||||
|
||||
console.log('--- ORGANIC ---');
|
||||
const organic = await syncAllConnectedOrganic({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : null,
|
||||
days: 28,
|
||||
});
|
||||
for (const r of organic.results) {
|
||||
const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId;
|
||||
console.log(
|
||||
` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- ADS ---');
|
||||
const ads = await syncAdsForTenants({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : undefined,
|
||||
days: 30,
|
||||
});
|
||||
for (const r of ads.results) {
|
||||
console.log(
|
||||
` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` +
|
||||
(r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- SUPERHUMAN ---');
|
||||
try {
|
||||
const sh = await syncAllSuperhumanTenants({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : null,
|
||||
maxEntries: 100,
|
||||
exportBodies: true,
|
||||
});
|
||||
for (const r of sh.results) {
|
||||
console.log(
|
||||
` ${r.tenantName}: ${r.status}` +
|
||||
(r.upserted ? ` · ${r.upserted}` : '') +
|
||||
(r.docName ? ` · ${r.docName}` : '') +
|
||||
(r.error ? ` · ${r.error.slice(0, 80)}` : '')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(' ', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
console.log('\n--- SOCIAL ---');
|
||||
const social = await syncSocial(tenants);
|
||||
if (!social.ok) console.log(' ', social.warning);
|
||||
for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) {
|
||||
console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`);
|
||||
}
|
||||
|
||||
console.log('\n--- SEO AUDIT ---');
|
||||
for (const r of await syncSeoAudits(tenants)) {
|
||||
console.log(
|
||||
` ${r.tenant}:` +
|
||||
(r.grade != null ? ` ${r.grade} (${r.score})` : '') +
|
||||
(r.error ? ` ERR ${r.error}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== DONE ===\n');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
316
scripts/sync_clientify_tenant.py
Normal file
316
scripts/sync_clientify_tenant.py
Normal file
@@ -0,0 +1,316 @@
|
||||
#!/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"))
|
||||
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,
|
||||
}
|
||||
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())
|
||||
Reference in New Issue
Block a user