feat: sync Meta metrics, objetivos ppto/consecución y simulador por tenant
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 <cursoragent@cursor.com>
This commit is contained in:
92
scripts/dokploy_apply_targets_budget.py
Normal file
92
scripts/dokploy_apply_targets_budget.py
Normal file
@@ -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())
|
||||||
13
sql/targets_budget.sql
Normal file
13
sql/targets_budget.sql
Normal file
@@ -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)';
|
||||||
44
src/app/api/admin/ads/sync/route.ts
Normal file
44
src/app/api/admin/ads/sync/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/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) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
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 });
|
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;
|
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||||
const companyName = user.user_metadata?.company || 'Cliente';
|
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
|
||||||
if (!tenantId) {
|
if (!tenantId && !ctx.portfolioWide) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
|
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
@@ -121,52 +130,85 @@ export async function POST(req: NextRequest) {
|
|||||||
normQuery.includes('presupuesto')
|
normQuery.includes('presupuesto')
|
||||||
) {
|
) {
|
||||||
try {
|
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\.,]*)/);
|
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
||||||
let amount = 1000;
|
let amount = 1000;
|
||||||
if (matchNumber) {
|
if (matchNumber) {
|
||||||
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
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';
|
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
|
||||||
else if (normQuery.includes('google')) channel = 'google';
|
else if (normQuery.includes('google')) channel = 'google';
|
||||||
else if (normQuery.includes('tiktok')) channel = 'tiktok';
|
else if (normQuery.includes('tiktok')) {
|
||||||
else if (normQuery.includes('linkedin')) channel = 'linkedin';
|
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
let daily: AdDailyRow[] = [];
|
||||||
method: 'POST',
|
let weekly: KpiWeeklyRow[] = [];
|
||||||
headers,
|
if (agency && ctx.viaView) {
|
||||||
body: JSON.stringify({
|
const tid = encodeURIComponent(tenantId);
|
||||||
p_tenant_id: tenantId,
|
const [d, w] = await Promise.all([
|
||||||
p_additional_spend: amount,
|
restGet<AdDailyRow[]>(
|
||||||
p_target_channel: channel
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||||
})
|
),
|
||||||
|
restGet<KpiWeeklyRow[]>(
|
||||||
|
`/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;
|
||||||
if (res.ok) {
|
const p50 = sim.expected_p50;
|
||||||
const sim = await res.json();
|
const p90 = sim.optimistic_p90;
|
||||||
const p10 = sim.conservative_p10 || {};
|
const base = sim.baseline;
|
||||||
const p50 = sim.expected_p50 || {};
|
const channelLabel = (sim.target_channel || 'blended').toUpperCase();
|
||||||
const p90 = sim.optimistic_p90 || {};
|
|
||||||
const base = sim.baseline || {};
|
|
||||||
|
|
||||||
const channelLabel = (sim.target_channel || 'General').toUpperCase();
|
|
||||||
const text =
|
const text =
|
||||||
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
|
`🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` +
|
||||||
`📈 **Métricas Base Observadas:**\n` +
|
`📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` +
|
||||||
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
`- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||||
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\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` +
|
`- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
|
||||||
`📊 **Proyección por Escenarios Matemáticos:**\n` +
|
`- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` +
|
||||||
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
|
`📊 **Proyección:**\n` +
|
||||||
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
|
`- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.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` +
|
`- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` +
|
||||||
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
|
`- **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);
|
return reply(text);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error in simulate_budget chat:', e);
|
console.error('Error in tenant simulate chat:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/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<AdDailyRow[]>(
|
||||||
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||||
|
),
|
||||||
|
restGet<KpiWeeklyRow[]>(
|
||||||
|
`/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) {
|
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 supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
@@ -13,42 +55,50 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId =
|
const ctx = await resolveActiveTenant(user);
|
||||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
if (!tenantId) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Usuario sin tenant_id' },
|
{ error: 'Selecciona un cliente en Contexto para simular.' },
|
||||||
{ status: 403 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const spend = parseFloat(body.spend || 1000);
|
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`, {
|
if (channel === 'tiktok') {
|
||||||
method: 'POST',
|
return NextResponse.json(
|
||||||
headers: {
|
{
|
||||||
apikey: keyForRpc,
|
error:
|
||||||
Authorization: `Bearer ${keyForRpc}`,
|
'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
{ status: 400 }
|
||||||
p_tenant_id: tenantId,
|
);
|
||||||
p_additional_spend: spend,
|
}
|
||||||
p_target_channel: channel === 'blended' ? null : channel,
|
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) {
|
return NextResponse.json({
|
||||||
const data = await res.json();
|
...sim,
|
||||||
return NextResponse.json(data);
|
tenant_label: ctx.label,
|
||||||
}
|
hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).',
|
||||||
const err = await res.text();
|
});
|
||||||
return NextResponse.json({ error: err }, { status: 500 });
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e instanceof Error ? e.message : 'Error';
|
const message = e instanceof Error ? e.message : 'Error';
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Target } from 'lucide-react';
|
import { Target, Wallet, Trophy } from 'lucide-react';
|
||||||
import BudgetSimulator from '@/components/BudgetSimulator';
|
import BudgetSimulator from '@/components/BudgetSimulator';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { AdDailyRow } from '@/lib/metrics';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -16,8 +17,57 @@ type TargetRow = {
|
|||||||
leads_actual?: number;
|
leads_actual?: number;
|
||||||
clients_target?: number;
|
clients_target?: number;
|
||||||
clients_actual?: 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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-300 font-medium">{label}</span>
|
||||||
|
<span className="text-white font-semibold tabular-nums">
|
||||||
|
{p}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${bar}`} style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 tabular-nums">
|
||||||
|
{actual.toLocaleString('es-ES')}
|
||||||
|
{unit} / {target.toLocaleString('es-ES')}
|
||||||
|
{unit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function EstrategiaPage() {
|
export default async function EstrategiaPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -28,7 +78,10 @@ export default async function EstrategiaPage() {
|
|||||||
const ctx = await resolveActiveTenant(user);
|
const ctx = await resolveActiveTenant(user);
|
||||||
const agency = isAgencyRole(userRole(user));
|
const agency = isAgencyRole(userRole(user));
|
||||||
const now = new Date();
|
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 = {
|
let targetData: TargetRow = {
|
||||||
revenue_target: 0,
|
revenue_target: 0,
|
||||||
@@ -38,28 +91,22 @@ export default async function EstrategiaPage() {
|
|||||||
leads_actual: 0,
|
leads_actual: 0,
|
||||||
clients_target: 0,
|
clients_target: 0,
|
||||||
clients_actual: 0,
|
clients_actual: 0,
|
||||||
|
spend_target: 0,
|
||||||
|
spend_actual: 0,
|
||||||
};
|
};
|
||||||
let sourceNote =
|
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;
|
const tenantForRpc = ctx.portfolioWide ? null : ctx.tenantId;
|
||||||
|
|
||||||
if (tenantForRpc) {
|
if (tenantForRpc) {
|
||||||
if (agency && ctx.viaView) {
|
if (agency && ctx.viaView) {
|
||||||
// Prefer raw targets row via service role; actuals may still need RPC with user JWT
|
|
||||||
const tid = encodeURIComponent(tenantForRpc);
|
const tid = encodeURIComponent(tenantForRpc);
|
||||||
const t = await restGet<
|
const t = await restGet<TargetRow[]>(
|
||||||
{ 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,spend_target,spend_actual`
|
||||||
>(
|
|
||||||
`/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target`
|
|
||||||
);
|
);
|
||||||
if (t.data?.[0]) {
|
if (t.data?.[0]) {
|
||||||
targetData = {
|
targetData = { ...targetData, ...t.data[0] };
|
||||||
...targetData,
|
|
||||||
revenue_target: t.data[0].revenue_target,
|
|
||||||
clients_target: t.data[0].clients_target,
|
|
||||||
leads_target: t.data[0].leads_target,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
||||||
p_month: currentMonth,
|
p_month: currentMonth,
|
||||||
@@ -68,6 +115,17 @@ export default async function EstrategiaPage() {
|
|||||||
if (!error && Array.isArray(data) && data[0]) {
|
if (!error && Array.isArray(data) && data[0]) {
|
||||||
targetData = { ...targetData, ...data[0] };
|
targetData = { ...targetData, ...data[0] };
|
||||||
}
|
}
|
||||||
|
// Actual spend from ad_daily this month
|
||||||
|
const d = await restGet<AdDailyRow[]>(
|
||||||
|
`/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 {
|
} else {
|
||||||
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
||||||
p_month: currentMonth,
|
p_month: currentMonth,
|
||||||
@@ -83,16 +141,42 @@ export default async function EstrategiaPage() {
|
|||||||
} else if (Array.isArray(data) && data[0]) {
|
} else if (Array.isArray(data) && data[0]) {
|
||||||
targetData = { ...targetData, ...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) {
|
} else if (ctx.portfolioWide) {
|
||||||
sourceNote =
|
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(
|
const spendTarget = Number(targetData.spend_target || 0);
|
||||||
100,
|
const spendActual = Number(targetData.spend_actual || 0);
|
||||||
Math.round(Number(targetData.revenue_achievement_pct) || 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -106,34 +190,97 @@ export default async function EstrategiaPage() {
|
|||||||
|
|
||||||
{ctx.portfolioWide ? (
|
{ctx.portfolioWide ? (
|
||||||
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
|
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
|
||||||
Elige un cliente arriba (Contexto) para ver facturación / leads /
|
Elige un cliente arriba (Contexto) para ver presupuesto y consecución
|
||||||
clientes objetivo vs real de ese tenant.
|
de objetivos de ese tenant.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<div className="flex items-center gap-2 text-brand-400 mb-4">
|
{/* Presupuesto */}
|
||||||
<Target className="w-4 h-4" />
|
<section className="bg-[#111827] border border-gray-800 rounded-xl p-6 space-y-5">
|
||||||
<span className="text-sm font-semibold">
|
<div className="flex items-center gap-2 text-amber-300">
|
||||||
Facturación {revenuePct}%
|
<Wallet className="w-4 h-4" />
|
||||||
</span>
|
<h3 className="text-sm font-bold uppercase tracking-wider">
|
||||||
|
Objetivos de presupuesto (ppto)
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
<p className="text-xs text-gray-500">
|
||||||
<div
|
Inversión ads planificada vs gasto real del mes (ad_daily).
|
||||||
className="h-full bg-brand-500"
|
|
||||||
style={{ width: `${revenuePct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
{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}
|
|
||||||
</p>
|
</p>
|
||||||
|
{spendTarget > 0 ? (
|
||||||
|
<ProgressBar
|
||||||
|
label="Presupuesto ads"
|
||||||
|
actual={spendActual}
|
||||||
|
target={spendTarget}
|
||||||
|
unit=" €"
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-700 px-3 py-4 text-xs text-gray-500">
|
||||||
|
Sin <code className="text-gray-400">spend_target</code> para este
|
||||||
|
mes en <code className="text-gray-400">targets</code>. Gasto
|
||||||
|
actual: {spendActual.toLocaleString('es-ES')} €.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div className="rounded-lg bg-black/25 border border-gray-800 px-3 py-2">
|
||||||
|
<p className="text-[10px] uppercase text-gray-500">Ppto</p>
|
||||||
|
<p className="text-lg font-bold text-white tabular-nums">
|
||||||
|
{spendTarget.toLocaleString('es-ES')} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-black/25 border border-gray-800 px-3 py-2">
|
||||||
|
<p className="text-[10px] uppercase text-gray-500">Gastado</p>
|
||||||
|
<p className="text-lg font-bold text-white tabular-nums">
|
||||||
|
{spendActual.toLocaleString('es-ES')} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Consecución */}
|
||||||
|
<section className="bg-[#111827] border border-gray-800 rounded-xl p-6 space-y-5">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-300">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider">
|
||||||
|
Objetivos de consecución
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Metas de negocio del mes vs resultado real (RPC targets_vs_actual).
|
||||||
|
</p>
|
||||||
|
<ProgressBar
|
||||||
|
label="Leads"
|
||||||
|
actual={leadsActual}
|
||||||
|
target={leadsTarget}
|
||||||
|
tone="sky"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Clientes"
|
||||||
|
actual={clientsActual}
|
||||||
|
target={clientsTarget}
|
||||||
|
tone="emerald"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Facturación"
|
||||||
|
actual={revenueActual}
|
||||||
|
target={revenueTarget}
|
||||||
|
unit=" €"
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-gray-500 pt-1">
|
||||||
|
<Target className="w-3.5 h-3.5 text-brand-400" />
|
||||||
|
Cumplimiento facturación:{' '}
|
||||||
|
{pct(revenueActual, revenueTarget) ||
|
||||||
|
Math.round(Number(targetData.revenue_achievement_pct) || 0)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BudgetSimulator />
|
{!ctx.portfolioWide ? (
|
||||||
|
<BudgetSimulator tenantLabel={ctx.label} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
buildComparisons,
|
buildComparisons,
|
||||||
sumDaily,
|
sumDaily,
|
||||||
sumWeekly,
|
sumWeekly,
|
||||||
|
weeklyFromDaily,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default async function KpisPage() {
|
|||||||
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
||||||
);
|
);
|
||||||
const d = await restGet<AdDailyRow[]>(
|
const d = await restGet<AdDailyRow[]>(
|
||||||
'/rest/v1/ad_daily?select=*&order=date.desc&limit=500'
|
'/rest/v1/ad_daily?select=*&order=date.desc&limit=5000'
|
||||||
);
|
);
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = d.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`
|
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
|
||||||
);
|
);
|
||||||
const d = await restGet<AdDailyRow[]>(
|
const d = await restGet<AdDailyRow[]>(
|
||||||
`/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 || [];
|
weekly = w.data || [];
|
||||||
daily = d.data || [];
|
daily = d.data || [];
|
||||||
@@ -59,11 +60,19 @@ export default async function KpisPage() {
|
|||||||
.from('ad_daily')
|
.from('ad_daily')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('date', { ascending: false })
|
.order('date', { ascending: false })
|
||||||
.limit(500);
|
.limit(5000);
|
||||||
weekly = (w as KpiWeeklyRow[]) || [];
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
daily = (d as AdDailyRow[]) || [];
|
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 comparisons = buildComparisons(weekly);
|
||||||
const month =
|
const month =
|
||||||
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
|
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default async function DashboardLayout({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">
|
<div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
Pipeline Windsor + n8n Activo
|
Sync Meta Ads · warehouse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function AdsDiscoverPanel() {
|
|||||||
const [gadsOk, setGadsOk] = useState(false);
|
const [gadsOk, setGadsOk] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [savingToken, setSavingToken] = useState(false);
|
const [savingToken, setSavingToken] = useState(false);
|
||||||
const [metaToken, setMetaToken] = useState('');
|
const [metaToken, setMetaToken] = useState('');
|
||||||
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
||||||
@@ -100,6 +101,41 @@ export default function AdsDiscoverPanel() {
|
|||||||
setRunning(false);
|
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(
|
async function assign(
|
||||||
provider: 'meta_ads' | 'google_ads',
|
provider: 'meta_ads' | 'google_ads',
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -150,6 +186,19 @@ export default function AdsDiscoverPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void syncMetrics()}
|
||||||
|
disabled={syncing || (!metaOk && !gadsOk)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-emerald-700/60 text-emerald-200 hover:border-emerald-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Sincronizar métricas
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void discover(['google_ads'])}
|
onClick={() => void discover(['google_ads'])}
|
||||||
|
|||||||
@@ -3,33 +3,82 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
export default function BudgetSimulator() {
|
type SimPayload = {
|
||||||
|
tenant_label?: string;
|
||||||
|
hint?: string;
|
||||||
|
target_channel?: string;
|
||||||
|
baseline?: {
|
||||||
|
historical_cpl?: number;
|
||||||
|
conversion_rate_pct?: number;
|
||||||
|
avg_ticket?: number;
|
||||||
|
sample_spend?: number;
|
||||||
|
sample_leads?: number;
|
||||||
|
sample_deals?: number;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
conservative_p10?: {
|
||||||
|
revenue?: number;
|
||||||
|
roas?: number;
|
||||||
|
leads?: number;
|
||||||
|
deals?: number;
|
||||||
|
};
|
||||||
|
expected_p50?: {
|
||||||
|
revenue?: number;
|
||||||
|
roas?: number;
|
||||||
|
leads?: number;
|
||||||
|
deals?: number;
|
||||||
|
};
|
||||||
|
optimistic_p90?: {
|
||||||
|
revenue?: number;
|
||||||
|
roas?: number;
|
||||||
|
leads?: number;
|
||||||
|
deals?: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ id: 'meta', label: 'Meta' },
|
||||||
|
{ id: 'google', label: 'Google' },
|
||||||
|
{ id: 'blended', label: 'Blended' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function BudgetSimulator({
|
||||||
|
tenantLabel,
|
||||||
|
}: {
|
||||||
|
tenantLabel?: string;
|
||||||
|
}) {
|
||||||
const [spend, setSpend] = useState<number>(1000);
|
const [spend, setSpend] = useState<number>(1000);
|
||||||
const [channel, setChannel] = useState<string>('meta');
|
const [channel, setChannel] = useState<string>('blended');
|
||||||
const [simulation, setSimulation] = useState<any>(null);
|
const [simulation, setSimulation] = useState<SimPayload | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchSimulation(spend, channel);
|
void fetchSimulation(spend, channel);
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [spend, channel]);
|
}, [spend, channel]);
|
||||||
|
|
||||||
async function fetchSimulation(amount: number, ch: string) {
|
async function fetchSimulation(amount: number, ch: string) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/simulate', {
|
const res = await fetch('/api/simulate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ spend: amount, channel: ch })
|
body: JSON.stringify({ spend: amount, channel: ch }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
const data = (await res.json()) as SimPayload;
|
||||||
const data = await res.json();
|
if (!res.ok) {
|
||||||
setSimulation(data);
|
setErr(data.error || 'Error al simular');
|
||||||
|
setSimulation(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setSimulation(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error simulating budget:', e);
|
setErr(e instanceof Error ? e.message : 'Error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,37 +95,48 @@ export default function BudgetSimulator() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calculator className="w-5 h-5 text-brand-500" />
|
<Calculator className="w-5 h-5 text-brand-500" />
|
||||||
<h3 className="text-lg font-bold text-white">Simulador Determinista "¿Qué pasa si...?"</h3>
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
Simulador «¿Qué pasa si…?»
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1 max-w-xl">
|
||||||
Proyección matemática basada en el histórico de CPL, tasa de cierre y ticket medio en Supabase.
|
Proyección con CPL, tasa de cierre y ticket medio de{' '}
|
||||||
|
<strong className="text-gray-300">
|
||||||
|
{simulation?.tenant_label || tenantLabel || 'este cliente'}
|
||||||
|
</strong>
|
||||||
|
. Sin TikTok (aún sin campañas activas).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{['meta', 'google', 'tiktok', 'blended'].map((ch) => (
|
{CHANNELS.map((ch) => (
|
||||||
<button
|
<button
|
||||||
key={ch}
|
key={ch.id}
|
||||||
onClick={() => setChannel(ch)}
|
type="button"
|
||||||
|
onClick={() => setChannel(ch.id)}
|
||||||
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||||
channel === ch
|
channel === ch.id
|
||||||
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
||||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ch}
|
{ch.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control del presupuesto */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs font-medium text-gray-300">
|
<label className="text-xs font-medium text-gray-300">
|
||||||
Inversión Adicional Proyectada
|
Inversión adicional proyectada
|
||||||
</label>
|
</label>
|
||||||
<span className="text-lg font-extrabold text-brand-400">
|
<span className="text-lg font-extrabold text-brand-400">
|
||||||
{spend.toLocaleString('es-ES')} €
|
{spend.toLocaleString('es-ES')} €
|
||||||
|
{loading ? (
|
||||||
|
<span className="text-[10px] text-gray-500 ml-2 font-normal">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -96,31 +156,51 @@ export default function BudgetSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Baseline Info */}
|
{err ? (
|
||||||
|
<p className="text-xs text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 bg-gray-900/60 p-3 rounded-lg border border-gray-800/80 text-xs text-gray-300">
|
<div className="flex flex-wrap items-center gap-3 bg-gray-900/60 p-3 rounded-lg border border-gray-800/80 text-xs text-gray-300">
|
||||||
<span className="text-gray-400">Parámetros Base Reales:</span>
|
<span className="text-gray-400">Baseline del cliente:</span>
|
||||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||||
CPL Histórico: <strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
CPL:{' '}
|
||||||
|
<strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||||
Tasa Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
|
Cierre:{' '}
|
||||||
|
<strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||||
Ticket Medio: <strong>{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €</strong>
|
Ticket:{' '}
|
||||||
|
<strong>
|
||||||
|
{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
|
{baseline.sample_spend != null ? (
|
||||||
|
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-400">
|
||||||
|
Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '}
|
||||||
|
{baseline.sample_leads || 0} leads
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Las 3 Bandas de Confianza */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
{/* Conservador p10 */}
|
|
||||||
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-gray-400">Conservador (p10)</span>
|
<span className="text-xs font-semibold text-gray-400">
|
||||||
|
Conservador (p10)
|
||||||
|
</span>
|
||||||
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-extrabold text-white">
|
<p className="text-2xl font-extrabold text-white">
|
||||||
+{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
+
|
||||||
|
{Number(p10.revenue || 0).toLocaleString('es-ES', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{' '}
|
||||||
|
€
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||||
ROAS {p10.roas || 0}x
|
ROAS {p10.roas || 0}x
|
||||||
@@ -128,28 +208,33 @@ export default function BudgetSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Leads estimados:</span>
|
<span>Leads:</span>
|
||||||
<strong className="text-gray-200">{p10.leads || 0}</strong>
|
<strong className="text-gray-200">{p10.leads || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Ventas cerradas:</span>
|
<span>Ventas:</span>
|
||||||
<strong className="text-gray-200">{p10.deals || 0}</strong>
|
<strong className="text-gray-200">{p10.deals || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Esperado p50 (Destacado) */}
|
|
||||||
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
||||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||||
Más Probable
|
Más probable
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-brand-300">Esperado (p50)</span>
|
<span className="text-xs font-semibold text-brand-300">
|
||||||
|
Esperado (p50)
|
||||||
|
</span>
|
||||||
<Sparkles className="w-4 h-4 text-brand-400" />
|
<Sparkles className="w-4 h-4 text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-extrabold text-white">
|
<p className="text-2xl font-extrabold text-white">
|
||||||
+{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
+
|
||||||
|
{Number(p50.revenue || 0).toLocaleString('es-ES', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{' '}
|
||||||
|
€
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-brand-400 font-semibold mt-1">
|
<p className="text-xs text-brand-400 font-semibold mt-1">
|
||||||
ROAS {p50.roas || 0}x
|
ROAS {p50.roas || 0}x
|
||||||
@@ -157,25 +242,30 @@ export default function BudgetSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-300 space-y-1">
|
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-300 space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Leads estimados:</span>
|
<span>Leads:</span>
|
||||||
<strong className="text-white">{p50.leads || 0}</strong>
|
<strong className="text-white">{p50.leads || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Ventas cerradas:</span>
|
<span>Ventas:</span>
|
||||||
<strong className="text-white">{p50.deals || 0}</strong>
|
<strong className="text-white">{p50.deals || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optimista p90 */}
|
|
||||||
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-gray-400">Optimista (p90)</span>
|
<span className="text-xs font-semibold text-gray-400">
|
||||||
|
Optimista (p90)
|
||||||
|
</span>
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-extrabold text-white">
|
<p className="text-2xl font-extrabold text-white">
|
||||||
+{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
+
|
||||||
|
{Number(p90.revenue || 0).toLocaleString('es-ES', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{' '}
|
||||||
|
€
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||||
ROAS {p90.roas || 0}x
|
ROAS {p90.roas || 0}x
|
||||||
@@ -183,11 +273,11 @@ export default function BudgetSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Leads estimados:</span>
|
<span>Leads:</span>
|
||||||
<strong className="text-gray-200">{p90.leads || 0}</strong>
|
<strong className="text-gray-200">{p90.leads || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Ventas cerradas:</span>
|
<span>Ventas:</span>
|
||||||
<strong className="text-gray-200">{p90.deals || 0}</strong>
|
<strong className="text-gray-200">{p90.deals || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
529
src/lib/ads-sync.ts
Normal file
529
src/lib/ads-sync.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<AdDailyInsert[]> {
|
||||||
|
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<AdDailyInsert[]> {
|
||||||
|
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<NonNullable<typeof j>['results']>[number];
|
||||||
|
j = {
|
||||||
|
results: parsed.flatMap(
|
||||||
|
(chunk: { results?: Row[] }) => chunk.results || []
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
j = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// NDJSON
|
||||||
|
const results: NonNullable<typeof j>['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<string, Agg>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -254,4 +254,34 @@ export function buildChannelSeries(rows: KpiWeeklyRow[]): {
|
|||||||
return { weeks, channels, points };
|
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<string, { spend: number; leads: number }>();
|
||||||
|
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 };
|
export { isoDate, weekStart };
|
||||||
|
|||||||
143
src/lib/simulate.ts
Normal file
143
src/lib/simulate.ts
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user