Compare commits
3 Commits
8cb5832f8b
...
a7f8ddad08
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f8ddad08 | |||
| 31193c57ee | |||
| ea201ef565 |
127
backfill_metricas_30junio.py
Normal file
127
backfill_metricas_30junio.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Reconstruye el día 30/06/2026 en MetricasDiarias para las GACampaignMes de
|
||||||
|
junio a las que les falta esa clave.
|
||||||
|
|
||||||
|
Motivo: en el cambio de mes (1 julio), run.py leía MetricasDiarias del
|
||||||
|
registro de julio (recién creado, casi vacío) en vez del de junio, así que
|
||||||
|
al redirigir la escritura hacia el registro de junio correcto se
|
||||||
|
sobrescribía con un dict casi vacío (perdiendo el día 30 real, que en
|
||||||
|
algunos casos quedó mal grabado en el registro de julio). Aquí se recalcula
|
||||||
|
el día 30 desde Google Ads (coste, conversiones) y Leads Lake, y se añade
|
||||||
|
SOLO a los registros de junio que no tienen ya esa clave — no se toca nada
|
||||||
|
más de su histórico.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python backfill_metricas_30junio.py # dry run — solo muestra
|
||||||
|
python backfill_metricas_30junio.py --apply # escribe en Airtable
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from airtable_client import AirtableClient
|
||||||
|
from google_ads_client import GoogleAdsClient
|
||||||
|
|
||||||
|
DATE_STR = "2026-06-30"
|
||||||
|
DAY_KEY = "30"
|
||||||
|
MES, ANIO = 6, 2026
|
||||||
|
|
||||||
|
|
||||||
|
def _course_num(name: str) -> str | None:
|
||||||
|
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def run(apply: bool):
|
||||||
|
at = AirtableClient()
|
||||||
|
gads = GoogleAdsClient()
|
||||||
|
|
||||||
|
formula = f"AND({{Mes}}='{MES}',{{Año}}='{ANIO}')"
|
||||||
|
records = at.gacampaignmes.all(
|
||||||
|
formula=formula,
|
||||||
|
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
|
||||||
|
)
|
||||||
|
campaigns_records = at.campaigns.all(fields=["CampaignID"])
|
||||||
|
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
|
||||||
|
|
||||||
|
campaigns = []
|
||||||
|
for r in records:
|
||||||
|
f = r["fields"]
|
||||||
|
at_cids = f.get("CampaignID", [])
|
||||||
|
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
name_list = f.get("Campaign Name (from CampaignID)", [])
|
||||||
|
campaigns.append({
|
||||||
|
"airtable_id": r["id"],
|
||||||
|
"google_campaign_id": gid,
|
||||||
|
"curso": name_list[0] if name_list else "Sin nombre",
|
||||||
|
"ppl": float(f.get("PPL") or 0),
|
||||||
|
"metricas_diarias": f.get("MetricasDiarias") or "{}",
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"→ {len(campaigns)} campañas de {MES}/{ANIO} en GACampaignMes")
|
||||||
|
|
||||||
|
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
|
||||||
|
cursoid_to_campaign: dict[str, str] = {}
|
||||||
|
for c in campaigns:
|
||||||
|
num = _course_num(c["curso"])
|
||||||
|
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
|
||||||
|
cursoid_to_campaign[num] = c["google_campaign_id"]
|
||||||
|
|
||||||
|
# Detectar registros a los que YA les falta el día 30
|
||||||
|
targets = []
|
||||||
|
for c in campaigns:
|
||||||
|
try:
|
||||||
|
md = json.loads(c["metricas_diarias"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
md = {}
|
||||||
|
if DAY_KEY not in md:
|
||||||
|
c["md"] = md
|
||||||
|
targets.append(c)
|
||||||
|
|
||||||
|
print(f"→ {len(targets)} campañas sin el día {DAY_KEY} en su MetricasDiarias\n")
|
||||||
|
if not targets:
|
||||||
|
print("Nada que hacer.")
|
||||||
|
return
|
||||||
|
|
||||||
|
ads_metrics = gads.get_metrics_for_date(DATE_STR)
|
||||||
|
leads_lake_counts = at.get_leads_by_campaign_on_date(DATE_STR, cursoid_to_campaign)
|
||||||
|
|
||||||
|
metricas_updates = []
|
||||||
|
print(f"{'Campaña':45} {'Coste':>10} {'Conv':>6} {'Ingreso':>10} {'Margen':>10} {'LeadsLake':>10}")
|
||||||
|
for c in targets:
|
||||||
|
cid = c["google_campaign_id"]
|
||||||
|
today_m = ads_metrics.get(cid, {})
|
||||||
|
coste_hoy = round(today_m.get("cost", 0), 2)
|
||||||
|
conv_hoy = today_m.get("conversions", 0)
|
||||||
|
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
|
||||||
|
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
|
||||||
|
leads_lake_hoy = leads_lake_counts.get(cid, 0)
|
||||||
|
|
||||||
|
print(f"{c['curso'][:45]:45} {coste_hoy:>9.2f}€ {conv_hoy:>6.0f} "
|
||||||
|
f"{ingreso_hoy:>9.2f}€ {margen_hoy:>9.2f}€ {leads_lake_hoy:>10}")
|
||||||
|
|
||||||
|
md = dict(c["md"])
|
||||||
|
md[DAY_KEY] = {
|
||||||
|
"coste": coste_hoy,
|
||||||
|
"ingreso": ingreso_hoy,
|
||||||
|
"margen": margen_hoy,
|
||||||
|
"leads": int(conv_hoy),
|
||||||
|
"leads_lake": leads_lake_hoy,
|
||||||
|
}
|
||||||
|
metricas_updates.append({
|
||||||
|
"airtable_id": c["airtable_id"],
|
||||||
|
"metricas_json": json.dumps(md, ensure_ascii=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
print()
|
||||||
|
if apply:
|
||||||
|
at.batch_update_metricas_diarias(metricas_updates)
|
||||||
|
print(f"✅ {len(metricas_updates)} registros actualizados en Airtable.")
|
||||||
|
else:
|
||||||
|
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
|
||||||
|
f"Ejecuta con --apply para escribir en Airtable.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(apply="--apply" in sys.argv)
|
||||||
142
backfill_metricas_junio_completo.py
Normal file
142
backfill_metricas_junio_completo.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Reconstruye los huecos en MetricasDiarias de las GACampaignMes de junio 2026:
|
||||||
|
|
||||||
|
- 35 campañas perdieron los días 01-29 (el bug de cambio de mes sobrescribió
|
||||||
|
su histórico completo dejando solo el día 30).
|
||||||
|
- 32 campañas no tienen el día 30 (nunca se llegó a escribir — coste y
|
||||||
|
conversiones fueron 0 ese día, confirmado contra Google Ads).
|
||||||
|
|
||||||
|
Recalcula cada día que falte a partir de Google Ads (coste, conversiones) y
|
||||||
|
Leads Lake (leads_lake), exactamente igual que lo habría hecho run.py cada
|
||||||
|
día, y hace merge con lo que ya existe en cada registro — no toca los días
|
||||||
|
que ya están bien.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python backfill_metricas_junio_completo.py # dry run
|
||||||
|
python backfill_metricas_junio_completo.py --apply # escribe en Airtable
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from airtable_client import AirtableClient
|
||||||
|
from google_ads_client import GoogleAdsClient
|
||||||
|
|
||||||
|
MES, ANIO = 6, 2026
|
||||||
|
FULL_MONTH_DAYS = [str(d).zfill(2) for d in range(1, 31)]
|
||||||
|
|
||||||
|
|
||||||
|
def _course_num(name: str) -> str | None:
|
||||||
|
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def run(apply: bool):
|
||||||
|
at = AirtableClient()
|
||||||
|
gads = GoogleAdsClient()
|
||||||
|
|
||||||
|
formula = f"AND({{Mes}}='{MES}',{{Año}}='{ANIO}')"
|
||||||
|
records = at.gacampaignmes.all(
|
||||||
|
formula=formula,
|
||||||
|
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
|
||||||
|
)
|
||||||
|
campaigns_records = at.campaigns.all(fields=["CampaignID"])
|
||||||
|
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
|
||||||
|
|
||||||
|
campaigns = []
|
||||||
|
for r in records:
|
||||||
|
f = r["fields"]
|
||||||
|
at_cids = f.get("CampaignID", [])
|
||||||
|
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
name_list = f.get("Campaign Name (from CampaignID)", [])
|
||||||
|
try:
|
||||||
|
md = json.loads(f.get("MetricasDiarias") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
md = {}
|
||||||
|
campaigns.append({
|
||||||
|
"airtable_id": r["id"],
|
||||||
|
"google_campaign_id": gid,
|
||||||
|
"curso": name_list[0] if name_list else "Sin nombre",
|
||||||
|
"ppl": float(f.get("PPL") or 0),
|
||||||
|
"md": md,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"→ {len(campaigns)} campañas de {MES}/{ANIO} en GACampaignMes")
|
||||||
|
|
||||||
|
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
|
||||||
|
cursoid_to_campaign: dict[str, str] = {}
|
||||||
|
for c in campaigns:
|
||||||
|
num = _course_num(c["curso"])
|
||||||
|
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
|
||||||
|
cursoid_to_campaign[num] = c["google_campaign_id"]
|
||||||
|
|
||||||
|
# Días que le faltan a cada campaña
|
||||||
|
for c in campaigns:
|
||||||
|
c["missing"] = [d for d in FULL_MONTH_DAYS if d not in c["md"]]
|
||||||
|
|
||||||
|
targets = [c for c in campaigns if c["missing"]]
|
||||||
|
all_missing_dates = sorted({f"{ANIO}-{MES:02d}-{d}" for c in targets for d in c["missing"]})
|
||||||
|
print(f"→ {len(targets)} campañas con días faltantes ({len(all_missing_dates)} fechas distintas a consultar)\n")
|
||||||
|
if not targets:
|
||||||
|
print("Nada que hacer.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("→ Consultando Google Ads (coste + conversiones diarias de junio)...")
|
||||||
|
daily_metrics = gads.get_daily_metrics_for_month(ANIO, MES)
|
||||||
|
|
||||||
|
print("→ Consultando Leads Lake por fecha...")
|
||||||
|
leads_lake_by_date = {}
|
||||||
|
for date_str in all_missing_dates:
|
||||||
|
leads_lake_by_date[date_str] = at.get_leads_by_campaign_on_date(date_str, cursoid_to_campaign)
|
||||||
|
print()
|
||||||
|
|
||||||
|
metricas_updates = []
|
||||||
|
total_coste = total_ingreso = 0.0
|
||||||
|
print(f"{'Campaña':45} {'Días':>5} {'Coste rec.':>11} {'Ingreso rec.':>13} {'Margen rec.':>12}")
|
||||||
|
for c in targets:
|
||||||
|
cid = c["google_campaign_id"]
|
||||||
|
md = dict(c["md"])
|
||||||
|
camp_coste = camp_ingreso = 0.0
|
||||||
|
for d in c["missing"]:
|
||||||
|
date_str = f"{ANIO}-{MES:02d}-{d}"
|
||||||
|
m = daily_metrics.get(date_str, {}).get(cid, {})
|
||||||
|
coste_hoy = round(m.get("cost", 0), 2)
|
||||||
|
conv_hoy = m.get("conversions", 0)
|
||||||
|
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
|
||||||
|
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
|
||||||
|
leads_lake_hoy = leads_lake_by_date.get(date_str, {}).get(cid, 0)
|
||||||
|
md[d] = {
|
||||||
|
"coste": coste_hoy,
|
||||||
|
"ingreso": ingreso_hoy,
|
||||||
|
"margen": margen_hoy,
|
||||||
|
"leads": int(conv_hoy),
|
||||||
|
"leads_lake": leads_lake_hoy,
|
||||||
|
}
|
||||||
|
camp_coste += coste_hoy
|
||||||
|
camp_ingreso += ingreso_hoy
|
||||||
|
|
||||||
|
total_coste += camp_coste
|
||||||
|
total_ingreso += camp_ingreso
|
||||||
|
print(f"{c['curso'][:45]:45} {len(c['missing']):>5} {camp_coste:>10.2f}€ "
|
||||||
|
f"{camp_ingreso:>12.2f}€ {camp_ingreso - camp_coste:>11.2f}€")
|
||||||
|
|
||||||
|
metricas_updates.append({
|
||||||
|
"airtable_id": c["airtable_id"],
|
||||||
|
"metricas_json": json.dumps(md, ensure_ascii=False, sort_keys=True),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n{'TOTAL recuperado':45} {'':>5} {total_coste:>10.2f}€ {total_ingreso:>12.2f}€ "
|
||||||
|
f"{total_ingreso - total_coste:>11.2f}€")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if apply:
|
||||||
|
at.batch_update_metricas_diarias(metricas_updates)
|
||||||
|
print(f"✅ {len(metricas_updates)} registros actualizados en Airtable.")
|
||||||
|
else:
|
||||||
|
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
|
||||||
|
f"Ejecuta con --apply para escribir en Airtable.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(apply="--apply" in sys.argv)
|
||||||
149
backfill_metricas_mes.py
Normal file
149
backfill_metricas_mes.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Reconstruye los huecos en MetricasDiarias de las GACampaignMes de un mes dado.
|
||||||
|
|
||||||
|
Motivo: el bug de cambio de mes en run.py (ver commit que añade merge con
|
||||||
|
get_metricas_diarias_prev_month) sobrescribía el histórico del mes saliente
|
||||||
|
con un dict casi vacío al redirigir la escritura del último día. Esto pasó en
|
||||||
|
todos los cambios de mes hasta que se arregló, dejando huecos — a veces todo
|
||||||
|
el mes salvo el último día, a veces solo el último día, a veces huecos
|
||||||
|
sueltos si Airtable falló algún día concreto.
|
||||||
|
|
||||||
|
Recalcula cada día que falte a partir de Google Ads (coste, conversiones) y
|
||||||
|
Leads Lake (leads_lake), exactamente igual que lo habría hecho run.py cada
|
||||||
|
día, y hace merge con lo que ya existe en cada registro — no toca ningún día
|
||||||
|
que ya esté guardado.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python backfill_metricas_mes.py <mes> <año> # dry run
|
||||||
|
python backfill_metricas_mes.py <mes> <año> --apply # escribe en Airtable
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from airtable_client import AirtableClient
|
||||||
|
from google_ads_client import GoogleAdsClient
|
||||||
|
|
||||||
|
|
||||||
|
def _course_num(name: str) -> str | None:
|
||||||
|
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def run(mes: int, anio: int, apply: bool):
|
||||||
|
at = AirtableClient()
|
||||||
|
gads = GoogleAdsClient()
|
||||||
|
|
||||||
|
days_in_month = calendar.monthrange(anio, mes)[1]
|
||||||
|
full_month_days = [str(d).zfill(2) for d in range(1, days_in_month + 1)]
|
||||||
|
|
||||||
|
formula = f"AND({{Mes}}='{mes}',{{Año}}='{anio}')"
|
||||||
|
records = at.gacampaignmes.all(
|
||||||
|
formula=formula,
|
||||||
|
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
|
||||||
|
)
|
||||||
|
campaigns_records = at.campaigns.all(fields=["CampaignID"])
|
||||||
|
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
|
||||||
|
|
||||||
|
campaigns = []
|
||||||
|
for r in records:
|
||||||
|
f = r["fields"]
|
||||||
|
at_cids = f.get("CampaignID", [])
|
||||||
|
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
name_list = f.get("Campaign Name (from CampaignID)", [])
|
||||||
|
try:
|
||||||
|
md = json.loads(f.get("MetricasDiarias") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
md = {}
|
||||||
|
campaigns.append({
|
||||||
|
"airtable_id": r["id"],
|
||||||
|
"google_campaign_id": gid,
|
||||||
|
"curso": name_list[0] if name_list else "Sin nombre",
|
||||||
|
"ppl": float(f.get("PPL") or 0),
|
||||||
|
"md": md,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"→ {len(campaigns)} campañas de {mes}/{anio} en GACampaignMes")
|
||||||
|
|
||||||
|
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
|
||||||
|
cursoid_to_campaign: dict[str, str] = {}
|
||||||
|
for c in campaigns:
|
||||||
|
num = _course_num(c["curso"])
|
||||||
|
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
|
||||||
|
cursoid_to_campaign[num] = c["google_campaign_id"]
|
||||||
|
|
||||||
|
# Días que le faltan a cada campaña
|
||||||
|
for c in campaigns:
|
||||||
|
c["missing"] = [d for d in full_month_days if d not in c["md"]]
|
||||||
|
|
||||||
|
targets = [c for c in campaigns if c["missing"]]
|
||||||
|
all_missing_dates = sorted({f"{anio}-{mes:02d}-{d}" for c in targets for d in c["missing"]})
|
||||||
|
print(f"→ {len(targets)} campañas con días faltantes ({len(all_missing_dates)} fechas distintas a consultar)\n")
|
||||||
|
if not targets:
|
||||||
|
print("Nada que hacer.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"→ Consultando Google Ads (coste + conversiones diarias de {mes}/{anio})...")
|
||||||
|
daily_metrics = gads.get_daily_metrics_for_month(anio, mes)
|
||||||
|
|
||||||
|
print("→ Consultando Leads Lake por fecha...")
|
||||||
|
leads_lake_by_date = {}
|
||||||
|
for date_str in all_missing_dates:
|
||||||
|
leads_lake_by_date[date_str] = at.get_leads_by_campaign_on_date(date_str, cursoid_to_campaign)
|
||||||
|
print()
|
||||||
|
|
||||||
|
metricas_updates = []
|
||||||
|
total_coste = total_ingreso = 0.0
|
||||||
|
print(f"{'Campaña':45} {'Días':>5} {'Coste rec.':>11} {'Ingreso rec.':>13} {'Margen rec.':>12}")
|
||||||
|
for c in targets:
|
||||||
|
cid = c["google_campaign_id"]
|
||||||
|
md = dict(c["md"])
|
||||||
|
camp_coste = camp_ingreso = 0.0
|
||||||
|
for d in c["missing"]:
|
||||||
|
date_str = f"{anio}-{mes:02d}-{d}"
|
||||||
|
m = daily_metrics.get(date_str, {}).get(cid, {})
|
||||||
|
coste_hoy = round(m.get("cost", 0), 2)
|
||||||
|
conv_hoy = m.get("conversions", 0)
|
||||||
|
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
|
||||||
|
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
|
||||||
|
leads_lake_hoy = leads_lake_by_date.get(date_str, {}).get(cid, 0)
|
||||||
|
md[d] = {
|
||||||
|
"coste": coste_hoy,
|
||||||
|
"ingreso": ingreso_hoy,
|
||||||
|
"margen": margen_hoy,
|
||||||
|
"leads": int(conv_hoy),
|
||||||
|
"leads_lake": leads_lake_hoy,
|
||||||
|
}
|
||||||
|
camp_coste += coste_hoy
|
||||||
|
camp_ingreso += ingreso_hoy
|
||||||
|
|
||||||
|
total_coste += camp_coste
|
||||||
|
total_ingreso += camp_ingreso
|
||||||
|
print(f"{c['curso'][:45]:45} {len(c['missing']):>5} {camp_coste:>10.2f}€ "
|
||||||
|
f"{camp_ingreso:>12.2f}€ {camp_ingreso - camp_coste:>11.2f}€")
|
||||||
|
|
||||||
|
metricas_updates.append({
|
||||||
|
"airtable_id": c["airtable_id"],
|
||||||
|
"metricas_json": json.dumps(md, ensure_ascii=False, sort_keys=True),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n{'TOTAL recuperado':45} {'':>5} {total_coste:>10.2f}€ {total_ingreso:>12.2f}€ "
|
||||||
|
f"{total_ingreso - total_coste:>11.2f}€")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if apply:
|
||||||
|
at.batch_update_metricas_diarias(metricas_updates)
|
||||||
|
print(f"✅ {len(metricas_updates)} registros actualizados en Airtable.")
|
||||||
|
else:
|
||||||
|
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
|
||||||
|
f"Ejecuta con --apply para escribir en Airtable.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
if len(args) != 2:
|
||||||
|
print("Uso: python backfill_metricas_mes.py <mes> <año> [--apply]")
|
||||||
|
sys.exit(1)
|
||||||
|
run(mes=int(args[0]), anio=int(args[1]), apply="--apply" in sys.argv)
|
||||||
76
dashboard.py
76
dashboard.py
@ -197,6 +197,39 @@ def _compute_analysis(row: dict, dia_actual: int, dias_mes: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SERIE_COLORS = {
|
||||||
|
"Inversión": "#2a78d6",
|
||||||
|
"Gasto": "#2a78d6",
|
||||||
|
"Ingreso": "#1baf7a",
|
||||||
|
"Revenue": "#1baf7a",
|
||||||
|
"Margen": "#4a3aa7",
|
||||||
|
"Margen (sumatorio)": "#4a3aa7",
|
||||||
|
"Margen (PPL)": "#eb6834",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _eur_line_chart(df: pd.DataFrame, series: list[str], height: int = 320):
|
||||||
|
"""Gráfico de líneas en € para comparar varias series por día. Un solo eje
|
||||||
|
(todas las series están en €), colores categóricos fijos y tooltip — evita
|
||||||
|
la ambigüedad de un gráfico de barras apiladas/agrupadas para comparar
|
||||||
|
magnitudes que pueden ser muy parecidas entre sí (p.ej. gasto vs ingreso)."""
|
||||||
|
series = [s for s in series if s in df.columns]
|
||||||
|
df_long = df.melt("Fecha", value_vars=series, var_name="Serie", value_name="Valor")
|
||||||
|
color_scale = alt.Scale(domain=series, range=[_SERIE_COLORS[s] for s in series])
|
||||||
|
chart = (
|
||||||
|
alt.Chart(df_long)
|
||||||
|
.mark_line(point=True, strokeWidth=2)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("Fecha:O", title=None),
|
||||||
|
y=alt.Y("Valor:Q", title="€"),
|
||||||
|
color=alt.Color("Serie:N", scale=color_scale, sort=series, legend=alt.Legend(title=None)),
|
||||||
|
tooltip=["Fecha", "Serie", alt.Tooltip("Valor:Q", format=",.2f")],
|
||||||
|
)
|
||||||
|
.properties(height=height)
|
||||||
|
)
|
||||||
|
st.altair_chart(chart, width="stretch")
|
||||||
|
|
||||||
|
|
||||||
def _daily_summary(rows: list[dict]) -> pd.DataFrame:
|
def _daily_summary(rows: list[dict]) -> pd.DataFrame:
|
||||||
"""Agrega Inversión/Ingreso/Margen día a día sumando las MetricasDiarias
|
"""Agrega Inversión/Ingreso/Margen día a día sumando las MetricasDiarias
|
||||||
de todas las campañas dadas, más un margen alternativo calculado con el
|
de todas las campañas dadas, más un margen alternativo calculado con el
|
||||||
@ -340,24 +373,7 @@ with tab_resumen:
|
|||||||
if df_summary.empty:
|
if df_summary.empty:
|
||||||
st.info("Sin métricas diarias disponibles para las campañas filtradas.")
|
st.info("Sin métricas diarias disponibles para las campañas filtradas.")
|
||||||
else:
|
else:
|
||||||
df_long = df_summary.melt("Fecha", var_name="Serie", value_name="Valor")
|
_eur_line_chart(df_summary, ["Inversión", "Ingreso", "Margen (sumatorio)", "Margen (PPL)"])
|
||||||
serie_order = ["Inversión", "Ingreso", "Margen (sumatorio)", "Margen (PPL)"]
|
|
||||||
color_scale = alt.Scale(
|
|
||||||
domain=serie_order,
|
|
||||||
range=["#2a78d6", "#1baf7a", "#4a3aa7", "#eb6834"],
|
|
||||||
)
|
|
||||||
chart = (
|
|
||||||
alt.Chart(df_long)
|
|
||||||
.mark_line(point=True, strokeWidth=2)
|
|
||||||
.encode(
|
|
||||||
x=alt.X("Fecha:O", title=None),
|
|
||||||
y=alt.Y("Valor:Q", title="€"),
|
|
||||||
color=alt.Color("Serie:N", scale=color_scale, sort=serie_order, legend=alt.Legend(title=None)),
|
|
||||||
tooltip=["Fecha", "Serie", alt.Tooltip("Valor:Q", format=",.2f")],
|
|
||||||
)
|
|
||||||
.properties(height=320)
|
|
||||||
)
|
|
||||||
st.altair_chart(chart, width="stretch")
|
|
||||||
st.caption("Margen (sumatorio) = Ingreso reportado − Gasto · Margen (PPL) = Leads del día × PPL de campaña − Gasto")
|
st.caption("Margen (sumatorio) = Ingreso reportado − Gasto · Margen (PPL) = Leads del día × PPL de campaña − Gasto")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
@ -610,14 +626,11 @@ with hist_camp_tab:
|
|||||||
df_ritmo = df_ritmo.set_index("Fecha")
|
df_ritmo = df_ritmo.set_index("Fecha")
|
||||||
st.line_chart(df_ritmo[["Leads acum.", "Objetivo"]])
|
st.line_chart(df_ritmo[["Leads acum.", "Objetivo"]])
|
||||||
|
|
||||||
# Gráfico gasto vs revenue
|
# Gráfico gasto / ingreso / margen diario
|
||||||
numeric_cols = [c for c in ["Gasto", "Revenue"] if c in df_daily.columns]
|
numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in df_daily.columns]
|
||||||
if numeric_cols:
|
if numeric_cols and "Fecha" in df_daily.columns:
|
||||||
st.markdown("**Gasto vs Revenue diario**")
|
st.markdown("**Gasto, ingreso y margen diario**")
|
||||||
chart_data = df_daily[numeric_cols].copy()
|
_eur_line_chart(df_daily.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
|
||||||
if "Fecha" in df_daily.columns:
|
|
||||||
chart_data.index = df_daily["Fecha"]
|
|
||||||
st.bar_chart(chart_data)
|
|
||||||
|
|
||||||
# Tabla detalle
|
# Tabla detalle
|
||||||
st.markdown("**Detalle diario**")
|
st.markdown("**Detalle diario**")
|
||||||
@ -680,13 +693,10 @@ with hist_portfolio_tab:
|
|||||||
col_map[c] = "Fecha"
|
col_map[c] = "Fecha"
|
||||||
gdf = gdf.rename(columns=col_map)
|
gdf = gdf.rename(columns=col_map)
|
||||||
|
|
||||||
numeric_cols = [c for c in ["Gasto", "Revenue"] if c in gdf.columns]
|
numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in gdf.columns]
|
||||||
if numeric_cols:
|
if numeric_cols and "Fecha" in gdf.columns:
|
||||||
st.markdown("**Portfolio: Gasto vs Revenue diario**")
|
st.markdown("**Portfolio: gasto, ingreso y margen diario**")
|
||||||
chart_data = gdf[numeric_cols].copy()
|
_eur_line_chart(gdf.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
|
||||||
if "Fecha" in gdf.columns:
|
|
||||||
chart_data.index = gdf["Fecha"]
|
|
||||||
st.bar_chart(chart_data)
|
|
||||||
else:
|
else:
|
||||||
st.info("Sin datos de portfolio para el período seleccionado.")
|
st.info("Sin datos de portfolio para el período seleccionado.")
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,70 @@ class GoogleAdsClient:
|
|||||||
print(f" ❌ Error obteniendo métricas de hoy: {e}")
|
print(f" ❌ Error obteniendo métricas de hoy: {e}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_metrics_for_date(self, date_str: str) -> dict:
|
||||||
|
"""
|
||||||
|
Devuelve métricas de una fecha concreta ('YYYY-MM-DD') para TODAS las
|
||||||
|
campañas en una sola query. Retorna dict {campaign_id: {conversions, cost}}.
|
||||||
|
"""
|
||||||
|
ga_service = self.client.get_service("GoogleAdsService")
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
campaign.id,
|
||||||
|
metrics.conversions,
|
||||||
|
metrics.cost_micros
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status != 'REMOVED'
|
||||||
|
AND segments.date = '{date_str}'
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
response = ga_service.search(customer_id=self.customer_id, query=query)
|
||||||
|
for row in response:
|
||||||
|
cid = str(row.campaign.id)
|
||||||
|
result[cid] = {
|
||||||
|
"conversions": row.metrics.conversions,
|
||||||
|
"cost": row.metrics.cost_micros / 1_000_000,
|
||||||
|
}
|
||||||
|
except GoogleAdsException as e:
|
||||||
|
print(f" ❌ Error obteniendo métricas del {date_str}: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_daily_metrics_for_month(self, year: int, month: int) -> dict:
|
||||||
|
"""
|
||||||
|
Devuelve coste y conversiones diarias de todas las campañas para un mes
|
||||||
|
completo. Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: {cost, conversions}}}.
|
||||||
|
"""
|
||||||
|
start = f"{year}-{month:02d}-01"
|
||||||
|
if month == 12:
|
||||||
|
end = f"{year + 1}-01-01"
|
||||||
|
else:
|
||||||
|
end = f"{year}-{month + 1:02d}-01"
|
||||||
|
ga_service = self.client.get_service("GoogleAdsService")
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
campaign.id,
|
||||||
|
segments.date,
|
||||||
|
metrics.conversions,
|
||||||
|
metrics.cost_micros
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status != 'REMOVED'
|
||||||
|
AND segments.date >= '{start}'
|
||||||
|
AND segments.date < '{end}'
|
||||||
|
"""
|
||||||
|
result: dict = {}
|
||||||
|
try:
|
||||||
|
response = ga_service.search(customer_id=self.customer_id, query=query)
|
||||||
|
for row in response:
|
||||||
|
date_str = row.segments.date
|
||||||
|
cid = str(row.campaign.id)
|
||||||
|
result.setdefault(date_str, {})[cid] = {
|
||||||
|
"conversions": row.metrics.conversions,
|
||||||
|
"cost": row.metrics.cost_micros / 1_000_000,
|
||||||
|
}
|
||||||
|
except GoogleAdsException as e:
|
||||||
|
print(f" ❌ Error obteniendo métricas diarias del mes: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
def get_daily_conversions_for_month(self, year: int, month: int) -> dict:
|
def get_daily_conversions_for_month(self, year: int, month: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Devuelve conversiones diarias de todas las campañas para un mes completo.
|
Devuelve conversiones diarias de todas las campañas para un mes completo.
|
||||||
|
|||||||
14
run.py
14
run.py
@ -398,8 +398,13 @@ def run():
|
|||||||
if metricas_updates:
|
if metricas_updates:
|
||||||
print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...")
|
print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...")
|
||||||
if cambio_mes:
|
if cambio_mes:
|
||||||
# Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto
|
# Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto.
|
||||||
|
# El "campaign" en memoria es el del mes nuevo (recién creado, MetricasDiarias
|
||||||
|
# casi vacío) — hay que hacer merge con el MetricasDiarias REAL del mes anterior
|
||||||
|
# antes de escribir, o se pierde el resto de días ya acumulados (bug que borró
|
||||||
|
# el histórico de junio salvo el día 30 al hacer un overwrite directo).
|
||||||
prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year)
|
prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year)
|
||||||
|
prev_metricas = at.get_metricas_diarias_prev_month()
|
||||||
redirected = []
|
redirected = []
|
||||||
omitidos = []
|
omitidos = []
|
||||||
for u in metricas_updates:
|
for u in metricas_updates:
|
||||||
@ -409,7 +414,14 @@ def run():
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if gid and gid in prev_map:
|
if gid and gid in prev_map:
|
||||||
|
try:
|
||||||
|
existing_md = json.loads(prev_metricas.get(gid, {}).get("metricas") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
existing_md = {}
|
||||||
|
nuevo_dia_md = json.loads(u["metricas_json"])
|
||||||
|
existing_md.update(nuevo_dia_md)
|
||||||
u["airtable_id"] = prev_map[gid]
|
u["airtable_id"] = prev_map[gid]
|
||||||
|
u["metricas_json"] = json.dumps(existing_md, ensure_ascii=False)
|
||||||
redirected.append(u)
|
redirected.append(u)
|
||||||
else:
|
else:
|
||||||
omitidos.append(gid)
|
omitidos.append(gid)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user