diff --git a/scripts/ingest_gads_json.py b/scripts/ingest_gads_json.py new file mode 100755 index 0000000..d45d819 --- /dev/null +++ b/scripts/ingest_gads_json.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Ingest .ops/gads/{customer_id}.json MCP dumps into ad_daily.""" +from __future__ import annotations +import json, os, sys, urllib.request, urllib.error +from pathlib import Path +from datetime import datetime, timezone, timedelta + +ROOT = Path('/Users/4ventures/4ventures-client-portal') +# load .env.local +for line in (ROOT/'.env.local').read_text().splitlines(): + if '=' in line and not line.startswith('#') and line.split('=',1)[0] not in os.environ: + k,v=line.split('=',1); os.environ[k]=v.strip().strip('"').strip("'") + +url=os.environ['NEXT_PUBLIC_SUPABASE_URL'].rstrip('/') +key=os.environ['SUPABASE_SERVICE_ROLE_KEY'] +h={'apikey':key,'Authorization':f'Bearer {key}','Content-Type':'application/json'} + +MAP={ + '3019377678':'a7a6eb49-435c-411e-bdb0-a45b6a75ffab', + '7658651782':'2f6587f1-5dab-4994-b99f-d62f4300dd92', + '4975185233':'25d3cdda-a3fc-4af7-b733-b38304b489df', + '3183140753':'506a6d00-dc5a-4d84-bedc-73a35e3014a6', + '8746480744':'98d023ba-1c5c-4cab-8ab9-4e70c4367bb1', + '7181044127':'6f5c19e4-bf8b-4ef0-8c7e-55c9d0c0a041', + '8436023380':'97def34e-e1c4-41b4-a579-036b14cd5091', + '2986275882':'229687b5-6861-4b6d-b321-e09197629731', + '6481978772':'8e5761b7-6347-4fd1-acbf-4572730a783a', + '9069365731':'73a1b415-2325-4cab-9769-823021a5e888', + '8177742252':'730feaf2-f2a3-4a18-963b-12b853551d8d', + '9627541456':'fd7d361b-c4d0-47ca-bc0e-75d21ac50da3', + '8592129580':'7df5293e-b954-4059-94da-d18534a08767', + '6871069003':'c538a248-4ef0-437b-b4a0-544cff597659', + '2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03', + '4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2', + '1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541', +} + +def call(method, path, body=None): + headers=dict(h); headers['Prefer']='return=minimal' + data=None if body is None else json.dumps(body).encode() + req=urllib.request.Request(f'{url}{path}', data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as r: + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode()[:400] + +since=(datetime.now(timezone.utc)-timedelta(days=30)).date().isoformat() +gads=ROOT/'.ops'/'gads' +total_rows=0; total_spend=0.0 +for path in sorted(gads.glob('*.json')): + cid=path.stem + tid=MAP.get(cid) + if not tid: + print('skip unknown', cid); continue + rows_raw=json.loads(path.read_text()) + if not rows_raw: + print(cid, 'EMPTY'); continue + out=[] + for r in rows_raw: + date=r.get('segments.date') or (r.get('segments') or {}).get('date') + if not date: continue + camp_id=r.get('campaign.id') or (r.get('campaign') or {}).get('id') + camp_name=r.get('campaign.name') or (r.get('campaign') or {}).get('name') + imp=r.get('metrics.impressions') or (r.get('metrics') or {}).get('impressions') or 0 + clicks=r.get('metrics.clicks') or (r.get('metrics') or {}).get('clicks') or 0 + cost=r.get('metrics.cost_micros') or (r.get('metrics') or {}).get('costMicros') or (r.get('metrics') or {}).get('cost_micros') or 0 + conv=r.get('metrics.conversions') or (r.get('metrics') or {}).get('conversions') or 0 + cid_s = str(camp_id) if camp_id is not None else '' + out.append({ + 'tenant_id': tid, + 'date': date, + 'platform': 'google', + 'campaign_id': cid_s or None, + 'campaign_name': camp_name, + # ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id + 'ad_id': cid_s, + 'ad_name': '', + 'impressions': int(float(imp or 0)), + 'clicks': int(float(clicks or 0)), + 'spend': round(float(cost or 0)/1_000_000, 2), + 'platform_leads': int(round(float(conv or 0))), + }) + call('DELETE', f'/rest/v1/ad_daily?tenant_id=eq.{tid}&platform=eq.google&date=gte.{since}') + ok=True + for i in range(0,len(out),400): + st,res=call('POST','/rest/v1/ad_daily', out[i:i+400]) + if st not in (200,201): + print('FAIL', cid, st, res); ok=False; break + if ok: + spend=sum(x['spend'] for x in out) + total_rows+=len(out); total_spend+=spend + print(f'OK {cid} rows={len(out)} spend={spend:.2f}') +print('TOTAL', total_rows, round(total_spend,2)) diff --git a/src/app/api/seguimiento/route.ts b/src/app/api/seguimiento/route.ts index 3e145cc..3051e24 100644 --- a/src/app/api/seguimiento/route.ts +++ b/src/app/api/seguimiento/route.ts @@ -11,6 +11,7 @@ import { AdDailyRow, KpiWeeklyRow, buildChannelSeries, + resolveWeeklyRows, sumDaily, sumWeekly, } from '@/lib/metrics'; @@ -76,7 +77,7 @@ export async function GET() { `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` ), restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=800` + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000` ), ]); entries = e.data || []; @@ -93,14 +94,15 @@ export async function GET() { supabase .from('ad_daily') .select('*') - .order('date', { ascending: true }) - .limit(800), + .order('date', { ascending: false }) + .limit(5000), ]); entries = (e as SeguimientoEntry[]) || []; weekly = (w as KpiWeeklyRow[]) || []; daily = (d as AdDailyRow[]) || []; } + weekly = resolveWeeklyRows(weekly, daily); const channelSeries = buildChannelSeries(weekly); const totalsWeekly = sumWeekly(weekly); const totalsDaily = sumDaily(daily); diff --git a/src/app/dashboard/kpis/canal/[channel]/page.tsx b/src/app/dashboard/kpis/canal/[channel]/page.tsx index 3c5ea89..5c83ea8 100644 --- a/src/app/dashboard/kpis/canal/[channel]/page.tsx +++ b/src/app/dashboard/kpis/canal/[channel]/page.tsx @@ -5,10 +5,12 @@ import { redirect } from 'next/navigation'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; import { + AdDailyRow, KpiWeeklyRow, buildChannelSeries, formatEur, formatNum, + resolveWeeklyRows, } from '@/lib/metrics'; import { ArrowLeft } from 'lucide-react'; @@ -40,20 +42,33 @@ export default async function CanalDashPage({ } let weekly: KpiWeeklyRow[] = []; + let daily: AdDailyRow[] = []; if (agency && ctx.viaView && ctx.tenantId) { const tid = encodeURIComponent(ctx.tenantId); - const w = await restGet( - `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` - ); + const [w, d] = await Promise.all([ + restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` + ), + restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000` + ), + ]); weekly = w.data || []; + daily = d.data || []; } else { - const { data } = await supabase - .from('kpi_weekly') - .select('*') - .order('week', { ascending: true }); - weekly = (data as KpiWeeklyRow[]) || []; + const [{ data: w }, { data: d }] = await Promise.all([ + supabase.from('kpi_weekly').select('*').order('week', { ascending: true }), + supabase + .from('ad_daily') + .select('*') + .order('date', { ascending: false }) + .limit(5000), + ]); + weekly = (w as KpiWeeklyRow[]) || []; + daily = (d as AdDailyRow[]) || []; } + weekly = resolveWeeklyRows(weekly, daily); const series = buildChannelSeries(weekly); const points = series.points.filter((p) => p.channel === channel); const spend = points.reduce((a, p) => a + p.spend, 0); diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index b6b44cf..1d0fb43 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -8,9 +8,9 @@ import { KpiWeeklyRow, buildChannelSeries, buildComparisons, + resolveWeeklyRows, sumDaily, sumWeekly, - weeklyFromDaily, } from '@/lib/metrics'; import { isAgencyRole, userRole } from '@/lib/agency'; @@ -65,13 +65,7 @@ export default async function KpisPage() { 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; - } + weekly = resolveWeeklyRows(weekly, daily); const comparisons = buildComparisons(weekly); const month = diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index c6edd99..ce12324 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -349,7 +349,8 @@ export default function MetricsDashboard({ {channelRows.length === 0 ? (

Sin series de canal en el periodo. Cuando Windsor cargue - `kpi_weekly.channel`, aparecerán aquí. + Los canales con inversión en ads (Meta, Google) aparecen cuando hay + filas en ad_daily o kpi_weekly para este cliente.

) : (
diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts index 42162a2..6841397 100644 --- a/src/lib/ads-sync.ts +++ b/src/lib/ads-sync.ts @@ -4,7 +4,7 @@ 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'; +const GADS_API = 'https://googleads.googleapis.com/v21'; export type SyncTenantResult = { tenantId: string; @@ -285,14 +285,16 @@ async function fetchGoogleCampaignDaily( const date = row.segments?.date; if (!date) continue; const spend = Math.round((n(row.metrics?.costMicros) / 1_000_000) * 100) / 100; + const campaignId = row.campaign?.id ? String(row.campaign.id) : null; out.push({ tenant_id: tenantId, date, platform: 'google', - campaign_id: row.campaign?.id ? String(row.campaign.id) : null, + campaign_id: campaignId, campaign_name: row.campaign?.name || null, - ad_id: null, - ad_name: null, + // ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id + ad_id: campaignId || '', + ad_name: '', impressions: n(row.metrics?.impressions), clicks: n(row.metrics?.clicks), spend, diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 6966e73..e1f22a2 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -284,4 +284,31 @@ export function weeklyFromDaily(rows: AdDailyRow[]): KpiWeeklyRow[] { }); } +const ADS_CHANNELS = new Set(['meta', 'google']); + +/** + * kpi_weekly is often a non-writable MV without paid channels. + * Prefer ad_daily rollups when they carry meta/google (or more spend). + */ +export function resolveWeeklyRows( + weekly: KpiWeeklyRow[], + daily: AdDailyRow[] +): KpiWeeklyRow[] { + const fromDaily = weeklyFromDaily(daily); + if (!fromDaily.length) return weekly; + + const weeklySpend = weekly.reduce((a, r) => a + n(r.total_spend), 0); + const dailySpend = daily.reduce((a, r) => a + n(r.spend), 0); + const weeklyAds = weekly.some((r) => + ADS_CHANNELS.has((r.channel || '').toLowerCase()) + ); + const dailyAds = daily.some((r) => + ADS_CHANNELS.has((r.platform || '').toLowerCase()) + ); + + if (dailyAds && !weeklyAds) return fromDaily; + if (dailySpend >= weeklySpend) return fromDaily; + return weekly; +} + export { isoDate, weekStart };