From a7f8ddad08afc0ef53e72752a7ad92ec5add655c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Fri, 3 Jul 2026 00:43:41 +0200 Subject: [PATCH] Add backfill scripts for MetricasDiarias gaps caused by the month-change bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-off scripts to reconstruct missing daily coste/ingreso/margen/leads from Google Ads and Leads Lake for days lost to the overwrite bug fixed in run.py. backfill_metricas_mes.py generalizes the approach (mes/año args) and was used to repair both mayo and junio 2026; the two junio- specific scripts document the narrower fixes applied first. --- backfill_metricas_30junio.py | 127 ++++++++++++++++++++++++ backfill_metricas_junio_completo.py | 142 ++++++++++++++++++++++++++ backfill_metricas_mes.py | 149 ++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 backfill_metricas_30junio.py create mode 100644 backfill_metricas_junio_completo.py create mode 100644 backfill_metricas_mes.py diff --git a/backfill_metricas_30junio.py b/backfill_metricas_30junio.py new file mode 100644 index 0000000..4cc8318 --- /dev/null +++ b/backfill_metricas_30junio.py @@ -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) diff --git a/backfill_metricas_junio_completo.py b/backfill_metricas_junio_completo.py new file mode 100644 index 0000000..73bacf4 --- /dev/null +++ b/backfill_metricas_junio_completo.py @@ -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) diff --git a/backfill_metricas_mes.py b/backfill_metricas_mes.py new file mode 100644 index 0000000..09248cf --- /dev/null +++ b/backfill_metricas_mes.py @@ -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 # dry run + python backfill_metricas_mes.py --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 [--apply]") + sys.exit(1) + run(mes=int(args[0]), anio=int(args[1]), apply="--apply" in sys.argv)