fix: alinear KPIs BPS al milímetro con Ads Manager y Clientify Online
Meta solo cuenta registration leads; Google guarda fracciones y redondea al sumar; ventas CRM usan pipeline Online con importe > 0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -71,21 +71,13 @@ 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')
|
||||
// Exact Ads Manager «Clientes potenciales» for Meta Leads CRM campaigns.
|
||||
// Do not fall back to lead / lead_grouped / pixel — those inflate past the
|
||||
// Ads Manager period total when mixed with registration actions.
|
||||
const primary = actions.find(
|
||||
(a) => a.action_type === 'offsite_complete_registration_add_meta_leads'
|
||||
);
|
||||
return anyLead ? n(anyLead.value) : 0;
|
||||
return primary ? n(primary.value) : 0;
|
||||
}
|
||||
|
||||
async function googleAccessToken(): Promise<string> {
|
||||
@@ -167,8 +159,9 @@ async function fetchMetaInsightsWindow(
|
||||
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)}` +
|
||||
// Campaign level: ad-level daily lead sums inflate vs Ads Manager period totals.
|
||||
`?fields=campaign_id,campaign_name,impressions,clicks,spend,actions,date_start` +
|
||||
`&level=campaign&time_increment=1&time_range=${encodeURIComponent(timeRange)}` +
|
||||
`&limit=500&access_token=${encodeURIComponent(token)}`;
|
||||
|
||||
while (url) {
|
||||
@@ -180,8 +173,6 @@ async function fetchMetaInsightsWindow(
|
||||
data?: {
|
||||
campaign_id?: string;
|
||||
campaign_name?: string;
|
||||
ad_id?: string;
|
||||
ad_name?: string;
|
||||
impressions?: string;
|
||||
clicks?: string;
|
||||
spend?: string;
|
||||
@@ -192,14 +183,16 @@ async function fetchMetaInsightsWindow(
|
||||
};
|
||||
for (const row of j.data || []) {
|
||||
if (!row.date_start) continue;
|
||||
const campaignId = row.campaign_id || null;
|
||||
out.push({
|
||||
tenant_id: tenantId,
|
||||
date: row.date_start,
|
||||
platform: 'meta',
|
||||
campaign_id: row.campaign_id || null,
|
||||
campaign_id: campaignId,
|
||||
campaign_name: row.campaign_name || null,
|
||||
ad_id: row.ad_id || null,
|
||||
ad_name: row.ad_name || null,
|
||||
// campaign-level rows: reuse campaign id as ad_id (NOT NULL column)
|
||||
ad_id: campaignId || '',
|
||||
ad_name: '',
|
||||
impressions: n(row.impressions),
|
||||
clicks: n(row.clicks),
|
||||
spend: Math.round(n(row.spend) * 100) / 100,
|
||||
@@ -327,28 +320,46 @@ async function fetchGoogleWindow(
|
||||
throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`);
|
||||
}
|
||||
|
||||
const out: AdDailyInsert[] = [];
|
||||
const rawRows: {
|
||||
date: string;
|
||||
campaignId: string | null;
|
||||
campaignName: string | null;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
spend: number;
|
||||
conversions: number;
|
||||
}[] = [];
|
||||
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;
|
||||
const campaignId = row.campaign?.id ? String(row.campaign.id) : null;
|
||||
out.push({
|
||||
tenant_id: tenantId,
|
||||
rawRows.push({
|
||||
date,
|
||||
platform: 'google',
|
||||
campaign_id: campaignId,
|
||||
campaign_name: row.campaign?.name || null,
|
||||
// ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id
|
||||
ad_id: campaignId || '',
|
||||
ad_name: '',
|
||||
campaignId,
|
||||
campaignName: row.campaign?.name || null,
|
||||
impressions: n(row.metrics?.impressions),
|
||||
clicks: n(row.metrics?.clicks),
|
||||
spend,
|
||||
platform_leads: Math.round(n(row.metrics?.conversions)),
|
||||
conversions: n(row.metrics?.conversions),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
// Keep fractional Google conversions (numeric column). UI totals use
|
||||
// Math.round(sum) once so any date range matches Ads Manager.
|
||||
return rawRows.map((r) => ({
|
||||
tenant_id: tenantId,
|
||||
date: r.date,
|
||||
platform: 'google',
|
||||
campaign_id: r.campaignId,
|
||||
campaign_name: r.campaignName,
|
||||
ad_id: r.campaignId || '',
|
||||
ad_name: '',
|
||||
impressions: r.impressions,
|
||||
clicks: r.clicks,
|
||||
spend: r.spend,
|
||||
platform_leads: Math.round(r.conversions * 1e6) / 1e6,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
|
||||
@@ -76,7 +76,8 @@ export function sumDaily(rows: AdDailyRow[]): {
|
||||
} {
|
||||
return {
|
||||
spend: rows.reduce((a, r) => a + n(r.spend), 0),
|
||||
leads: rows.reduce((a, r) => a + n(r.platform_leads), 0),
|
||||
// Round once after summing fractional Google conversions (Ads Manager style).
|
||||
leads: Math.round(rows.reduce((a, r) => a + n(r.platform_leads), 0)),
|
||||
impressions: rows.reduce((a, r) => a + n(r.impressions), 0),
|
||||
clicks: rows.reduce((a, r) => a + n(r.clicks), 0),
|
||||
};
|
||||
@@ -488,23 +489,25 @@ export function channelTotalsFromDaily(rows: AdDailyRow[]): {
|
||||
map.set(channel, cur);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([channel, v]) => ({
|
||||
channel,
|
||||
spend: v.spend,
|
||||
leads: v.leads,
|
||||
deals: 0,
|
||||
revenue: 0,
|
||||
cpl: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
roas: 0,
|
||||
}))
|
||||
.map(([channel, v]) => {
|
||||
const leads = Math.round(v.leads);
|
||||
return {
|
||||
channel,
|
||||
spend: v.spend,
|
||||
leads,
|
||||
deals: 0,
|
||||
revenue: 0,
|
||||
cpl: leads > 0 ? v.spend / leads : 0,
|
||||
roas: 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}
|
||||
|
||||
export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
|
||||
const spend = rows.reduce((a, r) => a + n(r.spend), 0);
|
||||
const leads = rows.reduce(
|
||||
(a, r) => a + n(r.platform_leads ?? r.conversions),
|
||||
0
|
||||
const leads = Math.round(
|
||||
rows.reduce((a, r) => a + n(r.platform_leads ?? r.conversions), 0)
|
||||
);
|
||||
return {
|
||||
spend,
|
||||
@@ -525,14 +528,32 @@ export type DealWonRow = {
|
||||
tenant_id?: string | null;
|
||||
/** Optional source/channel when joined from lead or Clientify contact. */
|
||||
source?: string | null;
|
||||
pipeline_id?: string | null;
|
||||
pipeline_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match Clientify board «Online» (paid won). If the tenant has an Online
|
||||
* pipeline, prefer it so totals match the board; otherwise keep all paid won.
|
||||
*/
|
||||
export function preferOnlinePaidDeals(deals: DealWonRow[]): DealWonRow[] {
|
||||
const paid = deals.filter((d) => n(d.amount) > 0);
|
||||
const online = paid.filter((d) => {
|
||||
const id = String(d.pipeline_id || '');
|
||||
const name = d.pipeline_name || '';
|
||||
return id === '77547' || /online/i.test(name);
|
||||
});
|
||||
return online.length ? online : paid;
|
||||
}
|
||||
|
||||
export function filterDealsForRange(
|
||||
deals: DealWonRow[],
|
||||
since: string | null,
|
||||
until: string | null
|
||||
): DealWonRow[] {
|
||||
const won = deals.filter((d) => !d.stage || d.stage === 'won');
|
||||
const won = preferOnlinePaidDeals(
|
||||
deals.filter((d) => !d.stage || d.stage === 'won')
|
||||
);
|
||||
if (!since || !until) return won;
|
||||
return won.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
@@ -566,7 +587,7 @@ export function mergeDealsIntoWeekly(
|
||||
deals: DealWonRow[]
|
||||
): KpiWeeklyRow[] {
|
||||
const byWeek = new Map<string, { deals: number; revenue: number }>();
|
||||
for (const d of deals) {
|
||||
for (const d of preferOnlinePaidDeals(deals)) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
if (!day) continue;
|
||||
@@ -901,7 +922,7 @@ export function attributionCross(
|
||||
cur.crmLeads += 1;
|
||||
}
|
||||
|
||||
for (const d of deals) {
|
||||
for (const d of preferOnlinePaidDeals(deals)) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const raw = (d.source || '').trim();
|
||||
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
||||
@@ -911,19 +932,22 @@ export function attributionCross(
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([channel, v]) => ({
|
||||
channel,
|
||||
spend: v.spend,
|
||||
platformLeads: v.platformLeads,
|
||||
crmLeads: v.crmLeads,
|
||||
gap: v.crmLeads - v.platformLeads,
|
||||
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||
ventas: v.ventas,
|
||||
importe: v.importe,
|
||||
roas: v.spend > 0 ? v.importe / v.spend : 0,
|
||||
cac: v.ventas > 0 ? v.spend / v.ventas : 0,
|
||||
}))
|
||||
.map(([channel, v]) => {
|
||||
const platformLeads = Math.round(v.platformLeads);
|
||||
return {
|
||||
channel,
|
||||
spend: v.spend,
|
||||
platformLeads,
|
||||
crmLeads: v.crmLeads,
|
||||
gap: v.crmLeads - platformLeads,
|
||||
cplPlatform: platformLeads > 0 ? v.spend / platformLeads : 0,
|
||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||
ventas: v.ventas,
|
||||
importe: v.importe,
|
||||
roas: v.spend > 0 ? v.importe / v.spend : 0,
|
||||
cac: v.ventas > 0 ? v.spend / v.ventas : 0,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.spend - a.spend ||
|
||||
|
||||
Reference in New Issue
Block a user