From a1a00840d5ed68b864895781637a03e0c505d75e Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 23 Sep 2026 10:59:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20sync=20Meta=20metrics,=20objetivos=20pp?= =?UTF-8?q?to/consecuci=C3=B3n=20y=20simulador=20por=20tenant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rellena ad_daily desde Meta, KPIs con fallback a daily, objetivos de presupuesto vs consecución, y simulador sin TikTok anclado a métricas del cliente. Co-authored-by: Cursor --- scripts/dokploy_apply_targets_budget.py | 92 +++++ sql/targets_budget.sql | 13 + src/app/api/admin/ads/sync/route.ts | 44 ++ src/app/api/chat/route.ts | 120 ++++-- src/app/api/simulate/route.ts | 108 +++-- src/app/dashboard/estrategia/page.tsx | 229 ++++++++-- src/app/dashboard/kpis/page.tsx | 15 +- src/app/dashboard/layout.tsx | 2 +- src/components/AdsDiscoverPanel.tsx | 49 +++ src/components/BudgetSimulator.tsx | 172 ++++++-- src/lib/ads-sync.ts | 529 ++++++++++++++++++++++++ src/lib/metrics.ts | 30 ++ src/lib/simulate.ts | 143 +++++++ 13 files changed, 1392 insertions(+), 154 deletions(-) create mode 100644 scripts/dokploy_apply_targets_budget.py create mode 100644 sql/targets_budget.sql create mode 100644 src/app/api/admin/ads/sync/route.ts create mode 100644 src/lib/ads-sync.ts create mode 100644 src/lib/simulate.ts diff --git a/scripts/dokploy_apply_targets_budget.py b/scripts/dokploy_apply_targets_budget.py new file mode 100644 index 0000000..b9df610 --- /dev/null +++ b/scripts/dokploy_apply_targets_budget.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Apply sql/targets_budget.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "targets_budget.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/tb.b64 /tmp/tb.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/tb.b64", 400) + run( + "base64 -d /tmp/tb.b64 > /tmp/tb.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tb.sql; echo EXIT:$?", + 14000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + print(text[-600:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/targets_budget.sql b/sql/targets_budget.sql new file mode 100644 index 0000000..6e00272 --- /dev/null +++ b/sql/targets_budget.sql @@ -0,0 +1,13 @@ +-- Monthly objectives: budget (ppto) + consecution targets +-- Idempotent. + +alter table public.targets + add column if not exists spend_target numeric; + +alter table public.targets + add column if not exists spend_actual numeric; + +comment on column public.targets.spend_target is + 'Presupuesto ads objetivo del mes (ppto)'; +comment on column public.targets.spend_actual is + 'Gasto ads real del mes (opcional; si null se calcula de ad_daily)'; diff --git a/src/app/api/admin/ads/sync/route.ts b/src/app/api/admin/ads/sync/route.ts new file mode 100644 index 0000000..b26007d --- /dev/null +++ b/src/app/api/admin/ads/sync/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { syncAdsForTenants } from '@/lib/ads-sync'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** POST { tenantId?, days? } — sync Meta/Google insights → ad_daily + kpi_weekly */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user || !isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const tenantId = + typeof body.tenantId === 'string' ? body.tenantId : undefined; + const days = + typeof body.days === 'number' && body.days > 0 && body.days <= 90 + ? body.days + : 30; + + try { + const { results, days: d } = await syncAdsForTenants({ tenantId, days }); + const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length; + const errs = results.filter((r) => r.errors.length).length; + return NextResponse.json({ + ok: true, + days: d, + synced: ok, + withErrors: errs, + results, + }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'sync error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2788028..a272fc0 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics'; +import { + SimChannel, + simulateFromTenantMetrics, +} from '@/lib/simulate'; export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; @@ -12,10 +19,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const tenantId = + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + const tenantId = ctx.tenantId || user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; - const companyName = user.user_metadata?.company || 'Cliente'; - if (!tenantId) { + const companyName = ctx.label || user.user_metadata?.company || 'Cliente'; + if (!tenantId && !ctx.portfolioWide) { return NextResponse.json( { error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' }, { status: 403 } @@ -121,52 +130,85 @@ export async function POST(req: NextRequest) { normQuery.includes('presupuesto') ) { try { + if (ctx.portfolioWide || !tenantId) { + return reply( + 'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).' + ); + } + const matchNumber = userMessage.match(/(\d+[\d\.,]*)/); let amount = 1000; if (matchNumber) { amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.')); } - let channel: string | null = null; + let channel: SimChannel = 'blended'; if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta'; else if (normQuery.includes('google')) channel = 'google'; - else if (normQuery.includes('tiktok')) channel = 'tiktok'; - else if (normQuery.includes('linkedin')) channel = 'linkedin'; - - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, { - method: 'POST', - headers, - body: JSON.stringify({ - p_tenant_id: tenantId, - p_additional_spend: amount, - p_target_channel: channel - }) - }); - - if (res.ok) { - const sim = await res.json(); - const p10 = sim.conservative_p10 || {}; - const p50 = sim.expected_p50 || {}; - const p90 = sim.optimistic_p90 || {}; - const base = sim.baseline || {}; - - const channelLabel = (sim.target_channel || 'General').toUpperCase(); - const text = - `🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` + - `📈 **Métricas Base Observadas:**\n` + - `- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` + - `- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` + - `- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` + - `📊 **Proyección por Escenarios Matemáticos:**\n` + - `- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` + - `- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` + - `- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` + - `💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`; - - return reply(text); + else if (normQuery.includes('tiktok')) { + return reply( + 'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.' + ); } + + let daily: AdDailyRow[] = []; + let weekly: KpiWeeklyRow[] = []; + if (agency && ctx.viaView) { + const tid = encodeURIComponent(tenantId); + const [d, w] = await Promise.all([ + restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400` + ), + restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52` + ), + ]); + daily = d.data || []; + weekly = w.data || []; + } else { + const [{ data: d }, { data: w }] = await Promise.all([ + supabase + .from('ad_daily') + .select('*') + .order('date', { ascending: false }) + .limit(400), + supabase + .from('kpi_weekly') + .select('*') + .order('week', { ascending: false }) + .limit(52), + ]); + daily = (d as AdDailyRow[]) || []; + weekly = (w as KpiWeeklyRow[]) || []; + } + + const sim = simulateFromTenantMetrics({ + spend: Number.isFinite(amount) && amount > 0 ? amount : 1000, + channel, + daily, + weekly, + }); + const p10 = sim.conservative_p10; + const p50 = sim.expected_p50; + const p90 = sim.optimistic_p90; + const base = sim.baseline; + const channelLabel = (sim.target_channel || 'blended').toUpperCase(); + const text = + `🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` + + `📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` + + `- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` + + `- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` + + `- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` + + `- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` + + `📊 **Proyección:**\n` + + `- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` + + `- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` + + `- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` + + `💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`; + + return reply(text); } catch (e) { - console.error('Error in simulate_budget chat:', e); + console.error('Error in tenant simulate chat:', e); } } diff --git a/src/app/api/simulate/route.ts b/src/app/api/simulate/route.ts index ac455a6..d651152 100644 --- a/src/app/api/simulate/route.ts +++ b/src/app/api/simulate/route.ts @@ -1,10 +1,52 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics'; +import { + SimChannel, + simulateFromTenantMetrics, +} from '@/lib/simulate'; + +export const dynamic = 'force-dynamic'; + +async function loadTenantMetrics(tenantId: string, agencyView: boolean) { + let daily: AdDailyRow[] = []; + let weekly: KpiWeeklyRow[] = []; + + if (agencyView) { + const tid = encodeURIComponent(tenantId); + const [d, w] = await Promise.all([ + restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400` + ), + restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52` + ), + ]); + daily = d.data || []; + weekly = w.data || []; + } else { + const supabase = await createClient(); + const [{ data: d }, { data: w }] = await Promise.all([ + supabase + .from('ad_daily') + .select('*') + .order('date', { ascending: false }) + .limit(400), + supabase + .from('kpi_weekly') + .select('*') + .order('week', { ascending: false }) + .limit(52), + ]); + daily = (d as AdDailyRow[]) || []; + weekly = (w as KpiWeeklyRow[]) || []; + } + return { daily, weekly }; +} export async function POST(req: NextRequest) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; - const supabase = await createClient(); const { data: { user }, @@ -13,42 +55,50 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const tenantId = - user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; - if (!tenantId) { + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { return NextResponse.json( - { error: 'Usuario sin tenant_id' }, - { status: 403 } + { error: 'Selecciona un cliente en Contexto para simular.' }, + { status: 400 } ); } - const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; - try { const body = await req.json(); const spend = parseFloat(body.spend || 1000); - const channel = body.channel || 'meta'; + let channel = String(body.channel || 'blended').toLowerCase(); - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, { - method: 'POST', - headers: { - apikey: keyForRpc, - Authorization: `Bearer ${keyForRpc}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - p_tenant_id: tenantId, - p_additional_spend: spend, - p_target_channel: channel === 'blended' ? null : channel, - }), + if (channel === 'tiktok') { + return NextResponse.json( + { + error: + 'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.', + }, + { status: 400 } + ); + } + if (!['meta', 'google', 'blended'].includes(channel)) { + channel = 'blended'; + } + + const agency = isAgencyRole(userRole(user)); + const { daily, weekly } = await loadTenantMetrics( + ctx.tenantId, + agency && ctx.viaView + ); + + const sim = simulateFromTenantMetrics({ + spend: Number.isFinite(spend) && spend > 0 ? spend : 1000, + channel: channel as SimChannel, + daily, + weekly, }); - if (res.ok) { - const data = await res.json(); - return NextResponse.json(data); - } - const err = await res.text(); - return NextResponse.json({ error: err }, { status: 500 }); + return NextResponse.json({ + ...sim, + tenant_label: ctx.label, + hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).', + }); } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Error'; return NextResponse.json({ error: message }, { status: 500 }); diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx index 9dc34fa..7fa77dc 100644 --- a/src/app/dashboard/estrategia/page.tsx +++ b/src/app/dashboard/estrategia/page.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Target } from 'lucide-react'; +import { Target, Wallet, Trophy } from 'lucide-react'; import BudgetSimulator from '@/components/BudgetSimulator'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; +import { AdDailyRow } from '@/lib/metrics'; export const dynamic = 'force-dynamic'; @@ -16,8 +17,57 @@ type TargetRow = { leads_actual?: number; clients_target?: number; clients_actual?: number; + spend_target?: number; + spend_actual?: number; }; +function pct(actual: number, target: number): number { + if (!target || target <= 0) return 0; + return Math.min(100, Math.round((actual / target) * 100)); +} + +function ProgressBar({ + label, + actual, + target, + unit = '', + tone = 'brand', +}: { + label: string; + actual: number; + target: number; + unit?: string; + tone?: 'brand' | 'sky' | 'emerald' | 'amber'; +}) { + const p = pct(actual, target); + const bar = + tone === 'sky' + ? 'bg-sky-500' + : tone === 'emerald' + ? 'bg-emerald-500' + : tone === 'amber' + ? 'bg-amber-500' + : 'bg-brand-500'; + return ( +
+
+ {label} + + {p}% + +
+
+
+
+

+ {actual.toLocaleString('es-ES')} + {unit} / {target.toLocaleString('es-ES')} + {unit} +

+
+ ); +} + export default async function EstrategiaPage() { const supabase = await createClient(); const { @@ -28,7 +78,10 @@ export default async function EstrategiaPage() { const ctx = await resolveActiveTenant(user); const agency = isAgencyRole(userRole(user)); const now = new Date(); - const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const currentMonth = `${y}-${m}-01`; + const monthPrefix = `${y}-${m}`; let targetData: TargetRow = { revenue_target: 0, @@ -38,28 +91,22 @@ export default async function EstrategiaPage() { leads_actual: 0, clients_target: 0, clients_actual: 0, + spend_target: 0, + spend_actual: 0, }; let sourceNote = - 'Origen: tabla targets + RPC targets_vs_actual (metas cargadas en Postgres por tenant/mes).'; + 'Objetivos de presupuesto (ppto ads) y de consecución (leads / clientes / facturación) por tenant y mes.'; const tenantForRpc = ctx.portfolioWide ? null : ctx.tenantId; if (tenantForRpc) { if (agency && ctx.viaView) { - // Prefer raw targets row via service role; actuals may still need RPC with user JWT const tid = encodeURIComponent(tenantForRpc); - const t = await restGet< - { revenue_target: number; clients_target: number; leads_target: number }[] - >( - `/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target` + const t = await restGet( + `/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target,spend_target,spend_actual` ); if (t.data?.[0]) { - targetData = { - ...targetData, - revenue_target: t.data[0].revenue_target, - clients_target: t.data[0].clients_target, - leads_target: t.data[0].leads_target, - }; + targetData = { ...targetData, ...t.data[0] }; } const { data, error } = await supabase.rpc('targets_vs_actual', { p_month: currentMonth, @@ -68,6 +115,17 @@ export default async function EstrategiaPage() { if (!error && Array.isArray(data) && data[0]) { targetData = { ...targetData, ...data[0] }; } + // Actual spend from ad_daily this month + const d = await restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${monthPrefix}-01&date=lte.${monthPrefix}-31&select=spend,date` + ); + const spendSum = (d.data || []).reduce( + (a, r) => a + Number(r.spend || 0), + 0 + ); + if (spendSum > 0 || !targetData.spend_actual) { + targetData.spend_actual = spendSum; + } } else { const { data, error } = await supabase.rpc('targets_vs_actual', { p_month: currentMonth, @@ -83,16 +141,42 @@ export default async function EstrategiaPage() { } else if (Array.isArray(data) && data[0]) { targetData = { ...targetData, ...data[0] }; } + + const { data: trow } = await supabase + .from('targets') + .select( + 'revenue_target,clients_target,leads_target,spend_target,spend_actual' + ) + .eq('month', currentMonth) + .maybeSingle(); + if (trow) targetData = { ...targetData, ...trow }; + + const { data: daily } = await supabase + .from('ad_daily') + .select('spend') + .gte('date', `${monthPrefix}-01`) + .lte('date', `${monthPrefix}-31`); + const spendSum = (daily || []).reduce( + (a, r) => a + Number((r as { spend?: number }).spend || 0), + 0 + ); + if (spendSum > 0 || !targetData.spend_actual) { + targetData.spend_actual = spendSum; + } } } else if (ctx.portfolioWide) { sourceNote = - 'Vista global: selecciona un cliente en el selector de contexto para ver sus objetivos del mes.'; + 'Vista global: selecciona un cliente en Contexto para ver ppto y consecución del mes.'; } - const revenuePct = Math.min( - 100, - Math.round(Number(targetData.revenue_achievement_pct) || 0) - ); + const spendTarget = Number(targetData.spend_target || 0); + const spendActual = Number(targetData.spend_actual || 0); + const leadsTarget = Number(targetData.leads_target || 0); + const leadsActual = Number(targetData.leads_actual || 0); + const clientsTarget = Number(targetData.clients_target || 0); + const clientsActual = Number(targetData.clients_actual || 0); + const revenueTarget = Number(targetData.revenue_target || 0); + const revenueActual = Number(targetData.revenue_actual || 0); return (
@@ -106,34 +190,97 @@ export default async function EstrategiaPage() { {ctx.portfolioWide ? (

- Elige un cliente arriba (Contexto) para ver facturación / leads / - clientes objetivo vs real de ese tenant. + Elige un cliente arriba (Contexto) para ver presupuesto y consecución + de objetivos de ese tenant.

) : ( -
-
- - - Facturación {revenuePct}% - -
-
-
+ {/* Presupuesto */} +
+
+ +

+ Objetivos de presupuesto (ppto) +

+
+

+ Inversión ads planificada vs gasto real del mes (ad_daily). +

+ {spendTarget > 0 ? ( + + ) : ( +
+ Sin spend_target para este + mes en targets. Gasto + actual: {spendActual.toLocaleString('es-ES')} €. +
+ )} +
+
+

Ppto

+

+ {spendTarget.toLocaleString('es-ES')} € +

+
+
+

Gastado

+

+ {spendActual.toLocaleString('es-ES')} € +

+
+
+
+ + {/* Consecución */} +
+
+ +

+ Objetivos de consecución +

+
+

+ Metas de negocio del mes vs resultado real (RPC targets_vs_actual). +

+ -
-

- {Number(targetData.revenue_actual || 0).toLocaleString('es-ES')} € /{' '} - {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € · - leads {targetData.leads_actual || 0}/{targetData.leads_target || 0} · - clientes {targetData.clients_actual || 0}/ - {targetData.clients_target || 0} -

+ + +
+ + Cumplimiento facturación:{' '} + {pct(revenueActual, revenueTarget) || + Math.round(Number(targetData.revenue_achievement_pct) || 0)} + % +
+
)} - + {!ctx.portfolioWide ? ( + + ) : null}
); } diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index d3706b5..b6b44cf 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -10,6 +10,7 @@ import { buildComparisons, sumDaily, sumWeekly, + weeklyFromDaily, } from '@/lib/metrics'; import { isAgencyRole, userRole } from '@/lib/agency'; @@ -35,7 +36,7 @@ export default async function KpisPage() { '/rest/v1/kpi_weekly?select=*&order=week.desc' ); const d = await restGet( - '/rest/v1/ad_daily?select=*&order=date.desc&limit=500' + '/rest/v1/ad_daily?select=*&order=date.desc&limit=5000' ); weekly = w.data || []; daily = d.data || []; @@ -45,7 +46,7 @@ export default async function KpisPage() { `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc` ); const d = await restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=500` + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000` ); weekly = w.data || []; daily = d.data || []; @@ -59,11 +60,19 @@ export default async function KpisPage() { .from('ad_daily') .select('*') .order('date', { ascending: false }) - .limit(500); + .limit(5000); weekly = (w as KpiWeeklyRow[]) || []; daily = (d as AdDailyRow[]) || []; } + // kpi_weekly is often a stale MV — prefer ad_daily when it has more signal + const fromDaily = weeklyFromDaily(daily); + const weeklySpend = weekly.reduce((a, r) => a + Number(r.total_spend || 0), 0); + const dailySpend = daily.reduce((a, r) => a + Number(r.spend || 0), 0); + if (fromDaily.length && dailySpend >= weeklySpend) { + weekly = fromDaily; + } + const comparisons = buildComparisons(weekly); const month = comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0 diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 2f09cfa..f7db414 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -169,7 +169,7 @@ export default async function DashboardLayout({
- Pipeline Windsor + n8n Activo + Sync Meta Ads · warehouse
diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx index 5da00ad..6109c75 100644 --- a/src/components/AdsDiscoverPanel.tsx +++ b/src/components/AdsDiscoverPanel.tsx @@ -27,6 +27,7 @@ export default function AdsDiscoverPanel() { const [gadsOk, setGadsOk] = useState(false); const [loading, setLoading] = useState(true); const [running, setRunning] = useState(false); + const [syncing, setSyncing] = useState(false); const [savingToken, setSavingToken] = useState(false); const [metaToken, setMetaToken] = useState(''); const [meta, setMeta] = useState(null); @@ -100,6 +101,41 @@ export default function AdsDiscoverPanel() { setRunning(false); } + async function syncMetrics() { + setSyncing(true); + setErr(null); + setMsg(null); + const res = await fetch('/api/admin/ads/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days: 30 }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Sync falló'); + } else { + const parts = (data.results || []) + .filter( + (r: { metaRows?: number; googleRows?: number }) => + (r.metaRows || 0) + (r.googleRows || 0) > 0 + ) + .map( + (r: { + tenantName: string; + metaRows: number; + googleRows: number; + }) => + `${r.tenantName}: M${r.metaRows}/G${r.googleRows}` + ); + setMsg( + `Métricas sincronizadas (${data.synced || 0} clientes, ${data.days || 30}d)` + + (parts.length ? ` · ${parts.slice(0, 6).join(' · ')}` : '') + + (data.withErrors ? ` · ${data.withErrors} con avisos` : '') + ); + } + setSyncing(false); + } + async function assign( provider: 'meta_ads' | 'google_ads', tenantId: string, @@ -150,6 +186,19 @@ export default function AdsDiscoverPanel() {
+ ))}
- {/* Control del presupuesto */}
{spend.toLocaleString('es-ES')} € + {loading ? ( + + … + + ) : null}
- {/* Baseline Info */} + {err ? ( +

+ {err} +

+ ) : null} +
- Parámetros Base Reales: + Baseline del cliente: - CPL Histórico: {Number(baseline.historical_cpl || 0).toFixed(2)} € + CPL:{' '} + {Number(baseline.historical_cpl || 0).toFixed(2)} € - Tasa Cierre: {baseline.conversion_rate_pct || 0}% + Cierre:{' '} + {baseline.conversion_rate_pct || 0}% - Ticket Medio: {Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} € + Ticket:{' '} + + {Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} € + + {baseline.sample_spend != null ? ( + + Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '} + {baseline.sample_leads || 0} leads + + ) : null}
- {/* Las 3 Bandas de Confianza */}
- {/* Conservador p10 */}
- Conservador (p10) + + Conservador (p10) +

- +{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € + + + {Number(p10.revenue || 0).toLocaleString('es-ES', { + minimumFractionDigits: 2, + })}{' '} + €

ROAS {p10.roas || 0}x @@ -128,28 +208,33 @@ export default function BudgetSimulator() {

- Leads estimados: + Leads: {p10.leads || 0}
- Ventas cerradas: + Ventas: {p10.deals || 0}
- {/* Esperado p50 (Destacado) */}
- Más Probable + Más probable
- Esperado (p50) + + Esperado (p50) +

- +{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € + + + {Number(p50.revenue || 0).toLocaleString('es-ES', { + minimumFractionDigits: 2, + })}{' '} + €

ROAS {p50.roas || 0}x @@ -157,25 +242,30 @@ export default function BudgetSimulator() {

- Leads estimados: + Leads: {p50.leads || 0}
- Ventas cerradas: + Ventas: {p50.deals || 0}
- {/* Optimista p90 */}
- Optimista (p90) + + Optimista (p90) +

- +{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € + + + {Number(p90.revenue || 0).toLocaleString('es-ES', { + minimumFractionDigits: 2, + })}{' '} + €

ROAS {p90.roas || 0}x @@ -183,11 +273,11 @@ export default function BudgetSimulator() {

- Leads estimados: + Leads: {p90.leads || 0}
- Ventas cerradas: + Ventas: {p90.deals || 0}
diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts new file mode 100644 index 0000000..42162a2 --- /dev/null +++ b/src/lib/ads-sync.ts @@ -0,0 +1,529 @@ +/** Pull Meta / Google Ads insights into ad_daily + roll up kpi_weekly. */ + +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { resolveMetaAccessToken } from '@/lib/ads-discovery'; + +const META_GRAPH = 'https://graph.facebook.com/v21.0'; +const GADS_API = 'https://googleads.googleapis.com/v18'; + +export type SyncTenantResult = { + tenantId: string; + tenantName: string; + metaRows: number; + googleRows: number; + weeklyRows: number; + errors: string[]; +}; + +type IntegrationRow = { + tenant_id: string; + provider: string; + external_account_id: string | null; + status?: string; +}; + +type TenantRow = { id: string; name: string }; + +type AdDailyInsert = { + tenant_id: string; + date: string; + platform: string; + campaign_id: string | null; + campaign_name: string | null; + ad_id: string | null; + ad_name: string | null; + impressions: number; + clicks: number; + spend: number; + platform_leads: number; +}; + +function n(v: unknown): number { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +function isoWeekMonday(d: Date): string { + const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const day = x.getUTCDay() || 7; + if (day !== 1) x.setUTCDate(x.getUTCDate() - (day - 1)); + return x.toISOString().slice(0, 10); +} + +function leadFromMetaActions( + actions?: { action_type?: string; value?: string }[] +): number { + if (!actions?.length) return 0; + const prefer = [ + 'lead', + 'onsite_conversion.lead_grouped', + 'offsite_conversion.fb_pixel_lead', + 'offsite_complete_registration_add_meta_leads', + 'complete_registration', + ]; + for (const key of prefer) { + const hit = actions.find((a) => a.action_type === key); + if (hit) return n(hit.value); + } + const anyLead = actions.find((a) => + (a.action_type || '').toLowerCase().includes('lead') + ); + return anyLead ? n(anyLead.value) : 0; +} + +async function googleAccessToken(): Promise { + const refresh = + process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || + process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); + const clientId = + process.env.GOOGLE_ADS_CLIENT_ID?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_ID?.trim(); + const clientSecret = + process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim(); + if (!refresh || !clientId || !clientSecret) { + throw new Error('Faltan credenciales Google Ads'); + } + const body = new URLSearchParams({ + refresh_token: refresh, + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + }); + const r = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`); + const j = (await r.json()) as { access_token: string }; + return j.access_token; +} + +function gadsHeaders(accessToken: string, loginCustomerId?: string) { + const h: Record = { + Authorization: `Bearer ${accessToken}`, + 'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(), + 'Content-Type': 'application/json', + }; + const login = + loginCustomerId || + process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, '') || + '5963150101'; // MCC 4Ventures + if (login) h['login-customer-id'] = login; + return h; +} + +async function fetchMetaAdDaily( + accountId: string, + tenantId: string, + days: number +): Promise { + const token = await resolveMetaAccessToken(); + if (!token) throw new Error('Sin token Meta'); + const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; + const since = new Date(); + since.setUTCDate(since.getUTCDate() - days); + const until = new Date(); + const timeRange = JSON.stringify({ + since: since.toISOString().slice(0, 10), + until: until.toISOString().slice(0, 10), + }); + + const out: AdDailyInsert[] = []; + let url: string | null = + `${META_GRAPH}/${act}/insights` + + `?fields=campaign_id,campaign_name,ad_id,ad_name,impressions,clicks,spend,actions,date_start` + + `&level=ad&time_increment=1&time_range=${encodeURIComponent(timeRange)}` + + `&limit=500&access_token=${encodeURIComponent(token)}`; + + while (url) { + const r = await fetch(url, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`Meta insights ${act}: ${(await r.text()).slice(0, 220)}`); + } + const j = (await r.json()) as { + data?: { + campaign_id?: string; + campaign_name?: string; + ad_id?: string; + ad_name?: string; + impressions?: string; + clicks?: string; + spend?: string; + actions?: { action_type?: string; value?: string }[]; + date_start?: string; + }[]; + paging?: { next?: string }; + }; + for (const row of j.data || []) { + if (!row.date_start) continue; + out.push({ + tenant_id: tenantId, + date: row.date_start, + platform: 'meta', + campaign_id: row.campaign_id || null, + campaign_name: row.campaign_name || null, + ad_id: row.ad_id || null, + ad_name: row.ad_name || null, + impressions: n(row.impressions), + clicks: n(row.clicks), + spend: Math.round(n(row.spend) * 100) / 100, + platform_leads: leadFromMetaActions(row.actions), + }); + } + url = j.paging?.next || null; + } + return out; +} + +async function fetchGoogleCampaignDaily( + customerId: string, + tenantId: string, + days: number, + accessToken: string +): Promise { + const cid = customerId.replace(/-/g, ''); + const since = new Date(); + since.setUTCDate(since.getUTCDate() - days); + const until = new Date(); + const sinceStr = since.toISOString().slice(0, 10); + const untilStr = until.toISOString().slice(0, 10); + + const query = ` + SELECT + campaign.id, + campaign.name, + segments.date, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions + FROM campaign + WHERE segments.date BETWEEN '${sinceStr}' AND '${untilStr}' + `; + + // Try with MCC login, then as self + const attempts = [ + gadsHeaders(accessToken), + gadsHeaders(accessToken, cid), + ]; + + let j: { + results?: { + campaign?: { id?: string; name?: string }; + segments?: { date?: string }; + metrics?: { + impressions?: string; + clicks?: string; + costMicros?: string; + conversions?: number; + }; + }[]; + } | null = null; + let lastErr = ''; + for (const headers of attempts) { + const r = await fetch(`${GADS_API}/customers/${cid}/googleAds:searchStream`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + if (r.ok) { + const raw = await r.text(); + // searchStream returns NDJSON array chunks or JSON array + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + type Row = NonNullable['results']>[number]; + j = { + results: parsed.flatMap( + (chunk: { results?: Row[] }) => chunk.results || [] + ), + }; + } else { + j = parsed; + } + } catch { + // NDJSON + const results: NonNullable['results'] = []; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line) as { results?: typeof results }; + results.push(...(chunk.results || [])); + } catch { + /* skip */ + } + } + j = { results }; + } + break; + } + lastErr = await r.text(); + } + if (!j) { + // fallback search (non-stream) + for (const headers of attempts) { + const r = await fetch(`${GADS_API}/customers/${cid}/googleAds:search`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + if (r.ok) { + j = (await r.json()) as typeof j; + break; + } + lastErr = await r.text(); + } + } + if (!j) { + throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`); + } + + const out: AdDailyInsert[] = []; + for (const row of j.results || []) { + const date = row.segments?.date; + if (!date) continue; + const spend = Math.round((n(row.metrics?.costMicros) / 1_000_000) * 100) / 100; + out.push({ + tenant_id: tenantId, + date, + platform: 'google', + campaign_id: row.campaign?.id ? String(row.campaign.id) : null, + campaign_name: row.campaign?.name || null, + ad_id: null, + ad_name: null, + impressions: n(row.metrics?.impressions), + clicks: n(row.metrics?.clicks), + spend, + platform_leads: Math.round(n(row.metrics?.conversions)), + }); + } + return out; +} + +async function replaceDaily( + tenantId: string, + platforms: string[], + rows: AdDailyInsert[], + days: number +) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const base = supabaseUrl(); + const since = new Date(); + since.setUTCDate(since.getUTCDate() - days); + const sinceStr = since.toISOString().slice(0, 10); + + for (const platform of platforms) { + await fetch( + `${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&platform=eq.${platform}&date=gte.${sinceStr}`, + { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } } + ); + } + + // chunk inserts + for (let i = 0; i < rows.length; i += 400) { + const chunk = rows.slice(i, i + 400); + const r = await fetch(`${base}/rest/v1/ad_daily`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(chunk), + }); + if (!r.ok) { + throw new Error(`insert ad_daily: ${(await r.text()).slice(0, 220)}`); + } + } +} + +async function upsertWeeklyFromDaily(tenantId: string, days: number) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const base = supabaseUrl(); + const since = new Date(); + since.setUTCDate(since.getUTCDate() - days); + const sinceStr = since.toISOString().slice(0, 10); + + const r = await fetch( + `${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`, + { headers, cache: 'no-store' } + ); + if (!r.ok) throw new Error(await r.text()); + const daily = (await r.json()) as { + date?: string; + platform?: string; + spend?: number; + platform_leads?: number; + }[]; + + type Agg = { spend: number; leads: number }; + const map = new Map(); + for (const row of daily) { + if (!row.date) continue; + const week = isoWeekMonday(new Date(`${row.date}T00:00:00Z`)); + const channel = (row.platform || 'other').toLowerCase(); + const key = `${week}|${channel}`; + const cur = map.get(key) || { spend: 0, leads: 0 }; + cur.spend += n(row.spend); + cur.leads += n(row.platform_leads); + map.set(key, cur); + } + + // wipe weekly ads channels in window then insert (keep non-ads channels if any) + const weeks = [...new Set([...map.keys()].map((k) => k.split('|')[0]))]; + for (const week of weeks) { + await fetch( + `${base}/rest/v1/kpi_weekly?tenant_id=eq.${encodeURIComponent(tenantId)}&week=eq.${week}&channel=in.(meta,google)`, + { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } } + ); + } + + const payload = [...map.entries()].map(([key, agg]) => { + const [week, channel] = key.split('|'); + return { + tenant_id: tenantId, + week: `${week}T00:00:00+00:00`, + channel, + total_leads: Math.round(agg.leads), + total_spend: Math.round(agg.spend * 100) / 100, + cohort_deals_won: 0, + cohort_revenue_won: 0, + cac_cohort: 0, + roas_cohort: 0, + }; + }); + + if (payload.length) { + // kpi_weekly may be a materialized view — best-effort only + const ins = await fetch(`${base}/rest/v1/kpi_weekly`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates,return=minimal', + }, + body: JSON.stringify(payload), + }); + if (!ins.ok) { + const msg = await ins.text(); + if (msg.includes('materialized view')) { + // UI falls back to ad_daily; skip hard failure + return 0; + } + throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`); + } + } + return payload.length; +} + +export async function syncAdsForTenants(opts?: { + tenantId?: string; + days?: number; +}): Promise<{ results: SyncTenantResult[]; days: number }> { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const base = supabaseUrl(); + const days = opts?.days ?? 30; + + const tRes = await fetch( + `${base}/rest/v1/tenants?select=id,name&order=name.asc`, + { headers, cache: 'no-store' } + ); + if (!tRes.ok) throw new Error(await tRes.text()); + let tenants = (await tRes.json()) as TenantRow[]; + if (opts?.tenantId) { + tenants = tenants.filter((t) => t.id === opts.tenantId); + } + + const iRes = await fetch( + `${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`, + { headers, cache: 'no-store' } + ); + if (!iRes.ok) throw new Error(await iRes.text()); + const integrations = (await iRes.json()) as IntegrationRow[]; + + let gToken: string | null = null; + try { + gToken = await googleAccessToken(); + } catch { + gToken = null; + } + + const results: SyncTenantResult[] = []; + + for (const tenant of tenants) { + const result: SyncTenantResult = { + tenantId: tenant.id, + tenantName: tenant.name, + metaRows: 0, + googleRows: 0, + weeklyRows: 0, + errors: [], + }; + const mine = integrations.filter((i) => i.tenant_id === tenant.id); + const rows: AdDailyInsert[] = []; + const platforms: string[] = []; + + const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id); + if (meta?.external_account_id) { + try { + const mrows = await fetchMetaAdDaily( + meta.external_account_id, + tenant.id, + days + ); + rows.push(...mrows); + platforms.push('meta'); + result.metaRows = mrows.length; + } catch (e) { + result.errors.push( + e instanceof Error ? e.message : 'meta sync error' + ); + } + } + + const gads = mine.find( + (i) => i.provider === 'google_ads' && i.external_account_id + ); + if (gads?.external_account_id) { + if (!gToken) { + result.errors.push('Google Ads sin token OAuth'); + } else { + try { + const grows = await fetchGoogleCampaignDaily( + gads.external_account_id, + tenant.id, + days, + gToken + ); + rows.push(...grows); + platforms.push('google'); + result.googleRows = grows.length; + } catch (e) { + result.errors.push( + e instanceof Error ? e.message : 'google sync error' + ); + } + } + } + + if (rows.length && platforms.length) { + try { + await replaceDaily(tenant.id, platforms, rows, days); + result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days); + } catch (e) { + result.errors.push( + e instanceof Error ? e.message : 'warehouse write error' + ); + } + } + + results.push(result); + } + + return { results, days }; +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 4f68120..6966e73 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -254,4 +254,34 @@ export function buildChannelSeries(rows: KpiWeeklyRow[]): { return { weeks, channels, points }; } +/** Build synthetic kpi_weekly-like rows from ad_daily (when MV is stale). */ +export function weeklyFromDaily(rows: AdDailyRow[]): KpiWeeklyRow[] { + const map = new Map(); + for (const r of rows) { + if (!r.date) continue; + const d = new Date(`${r.date}T00:00:00Z`); + if (Number.isNaN(d.getTime())) continue; + const week = isoDate(weekStart(d)); + const channel = (r.platform || 'otros').toLowerCase(); + const key = `${week}|${channel}`; + const cur = map.get(key) || { spend: 0, leads: 0 }; + cur.spend += n(r.spend); + cur.leads += n(r.platform_leads ?? r.conversions); + map.set(key, cur); + } + return [...map.entries()].map(([key, v]) => { + const [week, channel] = key.split('|'); + return { + week: `${week}T00:00:00+00:00`, + channel, + total_spend: Math.round(v.spend * 100) / 100, + total_leads: Math.round(v.leads), + cohort_deals_won: 0, + cohort_revenue_won: 0, + cac_cohort: 0, + roas_cohort: 0, + }; + }); +} + export { isoDate, weekStart }; diff --git a/src/lib/simulate.ts b/src/lib/simulate.ts new file mode 100644 index 0000000..267c1aa --- /dev/null +++ b/src/lib/simulate.ts @@ -0,0 +1,143 @@ +/** Deterministic budget simulation from tenant ad_daily + kpi_weekly. */ + +import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics'; +import { normalizePlatform } from '@/lib/ad-tracker-range'; + +export type SimChannel = 'meta' | 'google' | 'blended'; + +export type SimBaseline = { + historical_cpl: number; + conversion_rate_pct: number; + avg_ticket: number; + sample_spend: number; + sample_leads: number; + sample_deals: number; + sample_revenue: number; + channel: string; + source: 'tenant_metrics'; +}; + +export type SimBand = { + leads: number; + deals: number; + revenue: number; + roas: number; +}; + +export type SimResult = { + target_channel: string; + baseline: SimBaseline; + conservative_p10: SimBand; + expected_p50: SimBand; + optimistic_p90: SimBand; +}; + +function n(v: unknown): number { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +function matchChannel(platform: string | undefined, ch: SimChannel): boolean { + if (ch === 'blended') return true; + const p = normalizePlatform(platform); + if (ch === 'meta') return p === 'meta' || p === 'other'; + if (ch === 'google') return p === 'google'; + return false; +} + +function band( + spend: number, + cpl: number, + convRate: number, + ticket: number, + cplMult: number, + convMult: number +): SimBand { + const effectiveCpl = Math.max(1, cpl * cplMult); + const leads = Math.round(spend / effectiveCpl); + const deals = Math.round(leads * Math.min(0.95, convRate * convMult)); + const revenue = deals * ticket; + const roas = spend > 0 ? Math.round((revenue / spend) * 100) / 100 : 0; + return { leads, deals, revenue: Math.round(revenue * 100) / 100, roas }; +} + +/** Build simulation from this tenant's warehouse rows. */ +export function simulateFromTenantMetrics(opts: { + spend: number; + channel: SimChannel; + daily: AdDailyRow[]; + weekly: KpiWeeklyRow[]; +}): SimResult { + const { spend, channel, daily, weekly } = opts; + const dailyCh = daily.filter((r) => matchChannel(r.platform, channel)); + const srcDaily = dailyCh.length ? dailyCh : daily; + + const sampleSpend = srcDaily.reduce((a, r) => a + n(r.spend), 0); + const sampleLeads = srcDaily.reduce( + (a, r) => a + n(r.platform_leads ?? r.conversions), + 0 + ); + + const weeklyCh = weekly.filter((r) => { + if (channel === 'blended') return true; + const c = (r.channel || '').toLowerCase(); + if (channel === 'meta') { + return ( + c.includes('meta') || + c.includes('facebook') || + c.includes('instagram') || + c.includes('fb') + ); + } + if (channel === 'google') { + return c.includes('google') || c.includes('search') || c.includes('pmax'); + } + return true; + }); + const srcWeekly = weeklyCh.length ? weeklyCh : weekly; + + const wSpend = srcWeekly.reduce((a, r) => a + n(r.total_spend), 0); + const wLeads = srcWeekly.reduce((a, r) => a + n(r.total_leads), 0); + const wDeals = srcWeekly.reduce((a, r) => a + n(r.cohort_deals_won), 0); + const wRevenue = srcWeekly.reduce((a, r) => a + n(r.cohort_revenue_won), 0); + + const spendBase = sampleSpend > 0 ? sampleSpend : wSpend; + const leadsBase = sampleLeads > 0 ? sampleLeads : wLeads; + let historicalCpl = + leadsBase > 0 + ? spendBase / leadsBase + : sampleSpend > 0 + ? sampleSpend / Math.max(1, sampleLeads) + : 25; + if (!Number.isFinite(historicalCpl) || historicalCpl <= 0) historicalCpl = 25; + + const convRate = + wLeads > 0 + ? wDeals / wLeads + : wDeals > 0 && leadsBase > 0 + ? wDeals / leadsBase + : 0.12; + const avgTicket = + wDeals > 0 ? wRevenue / wDeals : wRevenue > 0 ? wRevenue : 800; + + const baseline: SimBaseline = { + historical_cpl: Math.round(historicalCpl * 100) / 100, + conversion_rate_pct: Math.round(Math.max(0.01, convRate) * 1000) / 10, + avg_ticket: Math.round(avgTicket * 100) / 100, + sample_spend: Math.round(spendBase * 100) / 100, + sample_leads: Math.round(leadsBase), + sample_deals: Math.round(wDeals), + sample_revenue: Math.round(wRevenue * 100) / 100, + channel: channel === 'blended' ? 'blended' : channel, + source: 'tenant_metrics', + }; + + const cr = Math.max(0.01, convRate); + return { + target_channel: baseline.channel, + baseline, + conservative_p10: band(spend, historicalCpl, cr, avgTicket, 1.35, 0.75), + expected_p50: band(spend, historicalCpl, cr, avgTicket, 1.0, 1.0), + optimistic_p90: band(spend, historicalCpl, cr, avgTicket, 0.75, 1.2), + }; +}