From 062c43325db4958708517aa837ff15fc37791663 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 28 Sep 2026 12:19:57 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20ventas=20KPI=20=3D=20Online=20Clientify?= =?UTF-8?q?=20(89)=20y=20atribuci=C3=B3n=20por=20fuente=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/sync_clientify_tenant.py | 21 +++++ sql/deals_source.sql | 4 + src/app/api/webhooks/clientify/route.ts | 21 ++++- src/app/dashboard/kpis/page.tsx | 110 ++++++++++++++++++++---- src/components/MetricsDashboard.tsx | 4 +- src/lib/metrics.ts | 73 ++++++++++++++-- 6 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 sql/deals_source.sql diff --git a/scripts/sync_clientify_tenant.py b/scripts/sync_clientify_tenant.py index a92c5f3..d0620de 100644 --- a/scripts/sync_clientify_tenant.py +++ b/scripts/sync_clientify_tenant.py @@ -281,6 +281,25 @@ def main() -> int: 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, @@ -292,6 +311,8 @@ def main() -> int: "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) diff --git a/sql/deals_source.sql b/sql/deals_source.sql new file mode 100644 index 0000000..360a23a --- /dev/null +++ b/sql/deals_source.sql @@ -0,0 +1,4 @@ +-- Attribution source on deals (Clientify deal_source / contact / lead). +ALTER TABLE public.deals + ADD COLUMN IF NOT EXISTS source text, + ADD COLUMN IF NOT EXISTS name text; diff --git a/src/app/api/webhooks/clientify/route.ts b/src/app/api/webhooks/clientify/route.ts index 9baa2f4..fe6fb84 100644 --- a/src/app/api/webhooks/clientify/route.ts +++ b/src/app/api/webhooks/clientify/route.ts @@ -268,11 +268,28 @@ async function upsertDeal(tenantId: string, d: Json) { } } const closed = str(d.actual_closed_date, 40); - const pipeUrl = str(d.pipeline, 200); + const pipeUrl = str(d.pipeline, 200) || ''; let pipelineId: string | null = null; if (pipeUrl.includes('/pipelines/')) { pipelineId = pipeUrl.replace(/\/$/, '').split('/').pop() || null; } + const tags = Array.isArray(d.tags) ? d.tags.map(String) : []; + const tagBlob = tags.join(' ').toLowerCase(); + const dealName = str(d.name, 200) || ''; + let source = + str(d.deal_source, 80) || + str(d.contact_source, 80) || + ''; + if (!source) { + if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tagBlob)) source = 'meta'; + else if (/google/.test(tagBlob)) source = 'google'; + else if ( + /woocommerce|ecommerce/.test(tagBlob) || + /^order\s+\d+/i.test(dealName) + ) { + source = 'ecommerce'; + } + } const row = { id, tenant_id: tenantId, @@ -284,6 +301,8 @@ async function upsertDeal(tenantId: string, d: Json) { lost_at: stage === 'lost' && closed ? closed : null, pipeline_id: pipelineId, pipeline_name: str(d.pipeline_desc, 120) || null, + source: source ? source.toLowerCase() : null, + name: dealName || null, }; const base = supabaseUrl(); await fetch( diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index c56a27a..3fadfa0 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -138,7 +138,10 @@ async function fetchWonDeals( tenantId: string | null, useServiceRole: boolean ): Promise { - const select = 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name'; + const select = + 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,source,name,lead_id'; + const selectFallback = + 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,lead_id'; const pageSize = 1000; const out: DealWonRow[] = []; @@ -149,32 +152,107 @@ async function fetchWonDeals( const tidFilter = tenantId ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` : ''; + let useSelect = select; for (let offset = 0; offset < 50000; offset += pageSize) { const r = await fetch( - `${base}/rest/v1/deals?select=${select}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`, + `${base}/rest/v1/deals?select=${useSelect}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`, { headers, cache: 'no-store' } ); - if (!r.ok) break; + if (!r.ok) { + if (useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + break; + } const chunk = (await r.json()) as DealWonRow[]; out.push(...chunk); if (chunk.length < pageSize) break; } - return out; + } else { + const supabase = await createClient(); + let useSelect = select; + for (let offset = 0; offset < 50000; offset += pageSize) { + const { data, error } = await supabase + .from('deals') + .select(useSelect) + .eq('stage', 'won') + .order('won_at', { ascending: false, nullsFirst: false }) + .range(offset, offset + pageSize - 1); + if (error && useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + if (error) break; + const chunk = (data as DealWonRow[]) || []; + out.push(...chunk); + if (chunk.length < pageSize) break; + } } - const supabase = await createClient(); - for (let offset = 0; offset < 50000; offset += pageSize) { - const { data } = await supabase - .from('deals') - .select(select) - .eq('stage', 'won') - .order('won_at', { ascending: false, nullsFirst: false }) - .range(offset, offset + pageSize - 1); - const chunk = (data as DealWonRow[]) || []; - out.push(...chunk); - if (chunk.length < pageSize) break; + return enrichDealSources(out, tenantId, useServiceRole); +} + +/** Fill deal.source from linked lead when missing. */ +async function enrichDealSources( + deals: DealWonRow[], + tenantId: string | null, + useServiceRole: boolean +): Promise { + const need = deals.filter((d) => !d.source && d.lead_id); + if (!need.length) { + return deals.map((d) => ({ + ...d, + source: + d.source || + (/^order\s+\d+/i.test(d.name || '') ? 'ecommerce' : d.source) || + null, + })); } - return out; + const leadIds = [...new Set(need.map((d) => d.lead_id!).filter(Boolean))]; + const leadSource = new Map(); + + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (headers && base) { + for (let i = 0; i < leadIds.length; i += 200) { + const chunk = leadIds.slice(i, i + 200); + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + const r = await fetch( + `${base}/rest/v1/leads?select=id,source&id=in.(${chunk.map(encodeURIComponent).join(',')})${tidFilter}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) continue; + const rows = (await r.json()) as { id: string; source?: string }[]; + for (const row of rows) { + if (row.source) leadSource.set(row.id, row.source); + } + } + } + } else { + const supabase = await createClient(); + for (let i = 0; i < leadIds.length; i += 200) { + const chunk = leadIds.slice(i, i + 200); + const { data } = await supabase + .from('leads') + .select('id,source') + .in('id', chunk); + for (const row of (data as { id: string; source?: string }[]) || []) { + if (row.source) leadSource.set(row.id, row.source); + } + } + } + + return deals.map((d) => { + let source = d.source || (d.lead_id ? leadSource.get(d.lead_id) : null) || null; + if (!source && /^order\s+\d+/i.test(d.name || '')) source = 'ecommerce'; + return { ...d, source }; + }); } export default async function KpisPage() { diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 1caaaa2..559bd2f 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -492,7 +492,9 @@ export default function MetricsDashboard({

Leads plat = conversiones ads. Leads CRM = altas Clientify por fuente. Ventas = nº deals ganados; Importe = amount Clientify. - Sin fuente de contacto → canal sin-atribuir. + Ventas = deals ganados Online (importe > 0), como el board + Clientify. Fuente del contacto → canal (meta / google / + ecommerce…). Sin fuente → sin-atribuir.

diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index a369f27..3093d9e 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -530,20 +530,26 @@ export type DealWonRow = { source?: string | null; pipeline_id?: string | null; pipeline_name?: string | null; + lead_id?: string | null; + name?: string | null; }; /** - * Match Clientify board «Online» (paid won). If the tenant has an Online - * pipeline, prefer it so totals match the board; otherwise keep all paid won. + * Match Clientify board «Online» (paid won). + * When an Online pipeline exists in the set, exclude other pipelines (e.g. Málaga). + * If no pipeline metadata at all, keep all paid won. */ export function preferOnlinePaidDeals(deals: DealWonRow[]): DealWonRow[] { const paid = deals.filter((d) => n(d.amount) > 0); - const online = paid.filter((d) => { + const withPipe = paid.filter((d) => d.pipeline_id || d.pipeline_name); + if (!withPipe.length) return paid; + const online = withPipe.filter((d) => { const id = String(d.pipeline_id || ''); - const name = d.pipeline_name || ''; - return id === '77547' || /online/i.test(name); + const name = (d.pipeline_name || '').trim(); + return id === '77547' || /^online$/i.test(name) || /^online\b/i.test(name); }); - return online.length ? online : paid; + if (online.length) return online; + return paid; } export function filterDealsForRange( @@ -653,12 +659,57 @@ export function normalizeLeadChannel(source?: string | null): string { ) { return 'google'; } + if ( + s === 'ecommerce' || + s === 'woocommerce' || + s === 'shopify' || + s.includes('woocommerce') || + s.includes('ecommerce') || + s.includes('blue parrot') + ) { + return 'ecommerce'; + } + if (s.includes('recomend') || s.includes('amigo') || s === 'referral') { + return 'referral'; + } + if ( + s.includes('nivel') || + s.includes('prueba') || + s.startsWith('test') || + s === 'general' || + s === 'organic' || + s === 'unknown' || + s === 'import' || + s === 'manual' + ) { + return 'organic'; + } if (s === 'inbox_messenger' || s === 'messenger') return 'messenger'; if (s.startsWith('integrations')) return 'integrations-api'; if (s === 'whatsapp') return 'whatsapp'; return s; } +/** Infer channel source for a won deal (Clientify deal_source / lead / order name). */ +export function inferDealSource(d: { + source?: string | null; + name?: string | null; + tags?: string[] | null; +}): string { + const raw = (d.source || '').trim(); + if (raw) return normalizeLeadChannel(raw); + const tags = (d.tags || []).join(' ').toLowerCase(); + if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tags)) return 'meta'; + if (/google/.test(tags)) return 'google'; + if ( + /woocommerce|ecommerce/.test(tags) || + /^order\s+\d+/i.test(d.name || '') + ) { + return 'ecommerce'; + } + return ''; +} + export function normalizeLeadStatus(status?: string | null): string { const s = (status || '').toLowerCase().trim(); if (!s) return 'sin-estado'; @@ -706,7 +757,9 @@ export function resolveWonCohort( deals: DealWonRow[], leads: CrmLeadRow[] ): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } { - const won = deals.filter((d) => !d.stage || d.stage === 'won'); + const won = preferOnlinePaidDeals( + deals.filter((d) => !d.stage || d.stage === 'won') + ); if (won.length) return { deals: won, source: 'deals' }; const fromLeads = clienteLeadsAsWonDeals(leads); if (fromLeads.length) return { deals: fromLeads, source: 'cliente_leads' }; @@ -924,8 +977,10 @@ export function attributionCross( for (const d of preferOnlinePaidDeals(deals)) { if (d.stage && d.stage !== 'won') continue; - const raw = (d.source || '').trim(); - const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir'; + const channel = + inferDealSource(d) || + (d.source ? normalizeLeadChannel(d.source) : '') || + 'sin-atribuir'; const cur = ensure(channel); cur.ventas += 1; cur.importe += n(d.amount);