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:
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 };
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
|
||||
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