From 83e2b59e835b2b4910fe7f189f31fa569a2233a2 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 28 Sep 2026 12:46:08 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20MoM=20mes-en-curso=20al=20mes=20previo?= =?UTF-8?q?=20real=20y=20pipelines=20CRM=20hist=C3=B3ricos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El filtro Online se aplica por rango de fechas; backfill de pipelines para que agosto/YoY no queden en 4 ventas. MoM de «este mes» usa los mismos días del mes anterior. Co-authored-by: Cursor --- scripts/backfill_deal_pipelines.ts | 202 +++++++++++++++++++++++++++++ src/lib/metrics.ts | 42 ++++-- 2 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 scripts/backfill_deal_pipelines.ts diff --git a/scripts/backfill_deal_pipelines.ts b/scripts/backfill_deal_pipelines.ts new file mode 100644 index 0000000..19c2678 --- /dev/null +++ b/scripts/backfill_deal_pipelines.ts @@ -0,0 +1,202 @@ +#!/usr/bin/env npx tsx +/** + * Backfill deals.pipeline_id / pipeline_name / source / name from Clientify + * for one tenant so KPI Online filter + attribution work for MoM/YoY. + * + * TENANT_SLUG=bps npx tsx scripts/backfill_deal_pipelines.ts + */ +import { config } from 'dotenv'; +config({ path: '.env.local' }); + +const CFY = 'https://api.clientify.com/v1'; +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + +function envKey(slug: string) { + const s = slug.toUpperCase().replace(/-/g, '_'); + return ( + process.env[`CLIENTIFY_API_KEY_${s}`] || + process.env.CLIENTIFY_API_KEY || + '' + ); +} + +function isPaidAds(s: string) { + const x = s.toLowerCase(); + return ( + /^(meta|facebook|instagram|google|google ads|google-ads|google_ads)/.test( + x + ) || + x.includes('facebook') || + x.includes('google ads') + ); +} + +function normalizeSource(s: string | null | undefined): string | null { + const x = (s || '').toLowerCase().trim(); + if (!x) return null; + if (isPaidAds(x)) return x.includes('google') ? 'google' : 'meta'; + if (x.includes('blue parrot') || x.includes('woocommerce') || x === 'ecommerce') + return 'ecommerce'; + if (x.includes('recomend') || x.includes('amigo')) return 'referral'; + if ( + x.includes('nivel') || + x.includes('prueba') || + x.startsWith('test') || + x === 'general' || + x === 'organic' || + x === 'unknown' || + x === 'import' || + x === 'manual' + ) + return 'organic'; + if (x.startsWith('integrations')) return 'integrations-api'; + return x.slice(0, 80); +} + +function inferSource(d: { + deal_source?: string; + contact_source?: string; + tags?: string[]; + name?: string; +}): string | null { + const fromDeal = d.deal_source || d.contact_source || ''; + const tags = (d.tags || []).join(' ').toLowerCase(); + if (isPaidAds(fromDeal)) return normalizeSource(fromDeal); + if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tags)) return 'meta'; + if (/google/.test(tags)) return 'google'; + if (fromDeal) return normalizeSource(fromDeal); + if (/woocommerce|ecommerce/.test(tags) || /^order\s+\d+/i.test(d.name || '')) + return 'ecommerce'; + return null; +} + +async function main() { + const slug = (process.env.TENANT_SLUG || 'bps').trim(); + const token = envKey(slug); + if (!token || !url || !key) throw new Error('missing env'); + + const tenants = await ( + await fetch( + `${url}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,name`, + { headers: { apikey: key, Authorization: `Bearer ${key}` } } + ) + ).json(); + const tid = tenants[0]?.id as string; + if (!tid) throw new Error(`tenant ${slug} not found`); + console.log('tenant', tenants[0].name, tid); + + // Load warehouse won deals needing backfill (or all) + const wh: { + id: string; + crm_deal_id: string; + lead_id: string | null; + pipeline_id: string | null; + }[] = []; + for (let offset = 0; offset < 100000; offset += 1000) { + const chunk = await ( + await fetch( + `${url}/rest/v1/deals?tenant_id=eq.${tid}&stage=eq.won&select=id,crm_deal_id,lead_id,pipeline_id&order=won_at.desc.nullslast&limit=1000&offset=${offset}`, + { headers: { apikey: key, Authorization: `Bearer ${key}` } } + ) + ).json(); + if (!Array.isArray(chunk) || !chunk.length) break; + wh.push(...chunk); + if (chunk.length < 1000) break; + } + const byCrm = new Map(wh.map((d) => [d.crm_deal_id.replace(/^clientify:/, ''), d])); + console.log('warehouse won', wh.length); + + // Lead sources for attribution priority + const leadSrc = new Map(); + const leadIds = [...new Set(wh.map((d) => d.lead_id).filter(Boolean))] as string[]; + for (let i = 0; i < leadIds.length; i += 200) { + const ids = leadIds.slice(i, i + 200); + const rows = await ( + await fetch( + `${url}/rest/v1/leads?id=in.(${ids.join(',')})&select=id,source`, + { headers: { apikey: key, Authorization: `Bearer ${key}` } } + ) + ).json(); + if (Array.isArray(rows)) { + for (const r of rows) if (r.source) leadSrc.set(r.id, r.source); + } + } + + let page = 1; + let patched = 0; + let seen = 0; + while (page <= 200) { + const j = await ( + await fetch( + `${CFY}/deals/?page_size=100&page=${page}&status=3&ordering=-actual_closed_date`, + { headers: { Authorization: `Token ${token}` } } + ) + ).json(); + const results = j.results || []; + if (!results.length) break; + + for (const d of results) { + seen++; + const did = String(d.id); + const row = byCrm.get(did); + if (!row) continue; + + const pipeUrl = String(d.pipeline || ''); + const pipeline_id = pipeUrl.includes('/pipelines/') + ? pipeUrl.replace(/\/$/, '').split('/').pop() || null + : null; + const pipeline_name = d.pipeline_desc || null; + + const fromLead = row.lead_id ? leadSrc.get(row.lead_id) : null; + let source: string | null = null; + if (fromLead && isPaidAds(fromLead)) source = normalizeSource(fromLead); + else { + const inferred = inferSource(d); + if (inferred && isPaidAds(inferred)) source = inferred; + else if (fromLead) source = normalizeSource(fromLead); + else source = inferred; + } + if ( + !source && + (pipeline_id === '77547' || /online/i.test(pipeline_name || '')) + ) { + source = 'ecommerce'; + } + + const r = await fetch(`${url}/rest/v1/deals?id=eq.${row.id}`, { + method: 'PATCH', + headers: { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + pipeline_id, + pipeline_name, + source, + name: d.name || null, + }), + }); + if (r.ok) patched++; + } + + const oldest = (results[results.length - 1].actual_closed_date || '').slice( + 0, + 10 + ); + console.log(`page ${page} seen=${seen} patched=${patched} oldest=${oldest}`); + if (!j.next) break; + // Stop if we've gone past all warehouse deals' likely range + if (oldest && oldest < '2024-01-01') break; + page++; + } + + console.log('DONE', { seen, patched, warehouse: wh.length }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 3093d9e..3af0f61 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -252,6 +252,23 @@ export function buildRangeDeltas( ); momSince = isoDate(prevMonthStart); momUntil = prevMonthEnd; + } else if ( + sinceD.getUTCDate() === 1 && + sinceD.getUTCMonth() === untilD.getUTCMonth() && + sinceD.getUTCFullYear() === untilD.getUTCFullYear() + ) { + // Month-to-date (e.g. 1–27 sep) → same days previous calendar month (1–27 ago). + const prev = new Date( + Date.UTC(sinceD.getUTCFullYear(), sinceD.getUTCMonth() - 1, 1) + ); + momSince = isoDate(prev); + const prevLast = new Date( + Date.UTC(prev.getUTCFullYear(), prev.getUTCMonth() + 1, 0) + ).getUTCDate(); + const day = Math.min(untilD.getUTCDate(), prevLast); + momUntil = isoDate( + new Date(Date.UTC(prev.getUTCFullYear(), prev.getUTCMonth(), day)) + ); } else { momUntil = shiftIsoDays(since, -1); momSince = shiftIsoDays(momUntil, -(span - 1)); @@ -557,15 +574,17 @@ export function filterDealsForRange( since: string | null, until: string | null ): DealWonRow[] { - const won = preferOnlinePaidDeals( - deals.filter((d) => !d.stage || d.stage === 'won') - ); - if (!since || !until) return won; - return won.filter((d) => { - const day = (d.won_at || '').slice(0, 10); - if (!day) return false; - return day >= since && day <= until; - }); + const won = deals.filter((d) => !d.stage || d.stage === 'won'); + const inRange = + since && until + ? won.filter((d) => { + const day = (d.won_at || '').slice(0, 10); + if (!day) return false; + return day >= since && day <= until; + }) + : won; + // Online filter after the date window so MoM/YoY months are independent. + return preferOnlinePaidDeals(inRange); } /** Overlay CRM won deals onto ad spend/leads totals. */ @@ -757,9 +776,8 @@ export function resolveWonCohort( deals: DealWonRow[], leads: CrmLeadRow[] ): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } { - const won = preferOnlinePaidDeals( - deals.filter((d) => !d.stage || d.stage === 'won') - ); + // Keep all won deals here; Online/paid filter runs per date range. + const won = 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' };