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:
96
scripts/dokploy_apply_ad_daily_leads_numeric.py
Normal file
96
scripts/dokploy_apply_ad_daily_leads_numeric.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/ad_daily_leads_numeric.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SQL = ROOT / "sql" / "ad_daily_leads_numeric.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
p = BPS / ".env"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(800)
|
||||
if "/dashboard" not in page.url:
|
||||
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||
page.locator('input[type="password"]').first.fill(password)
|
||||
page.locator('button[type="submit"]').first.click()
|
||||
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||
print("login ok", flush=True)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/adl.b64 /tmp/adl.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/adl.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/adl.b64 > /tmp/adl.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adl.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-1500:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
96
scripts/dokploy_apply_deals_pipeline.py
Normal file
96
scripts/dokploy_apply_deals_pipeline.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/deals_pipeline.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SQL = ROOT / "sql" / "deals_pipeline.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
p = BPS / ".env"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(800)
|
||||
if "/dashboard" not in page.url:
|
||||
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||
page.locator('input[type="password"]').first.fill(password)
|
||||
page.locator('button[type="submit"]').first.click()
|
||||
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||
print("login ok", flush=True)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/dp.b64 /tmp/dp.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/dp.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/dp.b64 > /tmp/dp.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/dp.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-1200:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -277,6 +277,10 @@ def main() -> int:
|
||||
lead_id = contact_to_lead.get(cid) if cid else None
|
||||
amount = float(d.get("amount") or d.get("amount_user") or 0)
|
||||
closed = parse_date(d.get("actual_closed_date"))
|
||||
pipe_url = str(d.get("pipeline") or "")
|
||||
pipe_id = ""
|
||||
if "/pipelines/" in pipe_url:
|
||||
pipe_id = pipe_url.rstrip("/").split("/")[-1]
|
||||
row = {
|
||||
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")),
|
||||
"tenant_id": tid,
|
||||
@@ -286,6 +290,8 @@ def main() -> int:
|
||||
"amount": amount,
|
||||
"won_at": closed if stage == "won" else None,
|
||||
"lost_at": closed if stage == "lost" else None,
|
||||
"pipeline_id": pipe_id or None,
|
||||
"pipeline_name": (d.get("pipeline_desc") or None),
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
|
||||
5
sql/ad_daily_leads_numeric.sql
Normal file
5
sql/ad_daily_leads_numeric.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Store fractional Google Ads conversions so period totals match Ads Manager
|
||||
-- (Math.round of the exact sum) for any date range.
|
||||
ALTER TABLE public.ad_daily
|
||||
ALTER COLUMN platform_leads TYPE numeric(14, 6)
|
||||
USING platform_leads::numeric(14, 6);
|
||||
4
sql/deals_pipeline.sql
Normal file
4
sql/deals_pipeline.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Pipeline on deals so KPI ventas can match Clientify board filters (e.g. Online).
|
||||
ALTER TABLE public.deals
|
||||
ADD COLUMN IF NOT EXISTS pipeline_id text,
|
||||
ADD COLUMN IF NOT EXISTS pipeline_name text;
|
||||
@@ -268,6 +268,11 @@ async function upsertDeal(tenantId: string, d: Json) {
|
||||
}
|
||||
}
|
||||
const closed = str(d.actual_closed_date, 40);
|
||||
const pipeUrl = str(d.pipeline, 200);
|
||||
let pipelineId: string | null = null;
|
||||
if (pipeUrl.includes('/pipelines/')) {
|
||||
pipelineId = pipeUrl.replace(/\/$/, '').split('/').pop() || null;
|
||||
}
|
||||
const row = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
@@ -277,6 +282,8 @@ async function upsertDeal(tenantId: string, d: Json) {
|
||||
amount: n(d.amount ?? d.amount_user),
|
||||
won_at: stage === 'won' && closed ? closed : null,
|
||||
lost_at: stage === 'lost' && closed ? closed : null,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: str(d.pipeline_desc, 120) || null,
|
||||
};
|
||||
const base = supabaseUrl();
|
||||
await fetch(
|
||||
|
||||
@@ -138,7 +138,7 @@ async function fetchWonDeals(
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<DealWonRow[]> {
|
||||
const select = 'amount,won_at,stage,tenant_id';
|
||||
const select = 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name';
|
||||
const pageSize = 1000;
|
||||
const out: DealWonRow[] = [];
|
||||
|
||||
|
||||
@@ -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