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:
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