fix: mostrar Google Ads desde ad_daily cuando kpi_weekly está vacío
kpi_weekly es una MV de solo lectura sin canales paid; canal/seguimiento ahora hacen fallback a ad_daily. Bump Google Ads API a v21 en sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
94
scripts/ingest_gads_json.py
Executable file
94
scripts/ingest_gads_json.py
Executable file
@@ -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))
|
||||
@@ -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<AdDailyRow[]>(
|
||||
`/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);
|
||||
|
||||
@@ -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<KpiWeeklyRow[]>(
|
||||
const [w, d] = await Promise.all([
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
|
||||
);
|
||||
),
|
||||
restGet<AdDailyRow[]>(
|
||||
`/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')
|
||||
const [{ data: w }, { data: d }] = await Promise.all([
|
||||
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('week', { ascending: true });
|
||||
weekly = (data as KpiWeeklyRow[]) || [];
|
||||
.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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -349,7 +349,8 @@ export default function MetricsDashboard({
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user