diff --git a/airtable_client.py b/airtable_client.py index 783fade..03f6749 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -14,6 +14,7 @@ class AirtableClient: self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso") self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes") self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes") + self.games = self.api.table(config.AIRTABLE_BASE_ID, "GAMes") MESES_ES = { 1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril", @@ -478,6 +479,25 @@ class AirtableClient: for i in range(0, len(batch), 10): self.gacampaignmes.batch_update(batch[i:i+10]) + # ── GAMes ──────────────────────────────────────────────────────────────── # + + def get_or_create_games_record(self, mes: int, anio: int) -> str: + records = self.games.all(formula=f"AND({{Mes}}='{mes}',{{Año}}='{anio}')") + if records: + return records[0]["id"] + r = self.games.create({"Mes": str(mes), "Año": str(anio)}) + return r["id"] + + def update_games_metricas(self, record_id: str, metricas_json: str) -> None: + self.games.update(record_id, {"MetricasDiarias": metricas_json}) + + def get_games_metricas(self, record_id: str) -> dict: + r = self.games.get(record_id) + try: + return json.loads(r["fields"].get("MetricasDiarias") or "{}") + except (json.JSONDecodeError, TypeError): + return {} + def batch_update_gacampaignmes_advice(self, updates: list[tuple]) -> None: """ Actualiza en lote los campos 'Consejo', 'Criticidad' y 'Log' de GACampaignMes. diff --git a/backfill_games_mayo.py b/backfill_games_mayo.py new file mode 100644 index 0000000..c0ac552 --- /dev/null +++ b/backfill_games_mayo.py @@ -0,0 +1,73 @@ +""" +Script one-off: crea el registro de mayo 2026 en GAMes y rellena MetricasDiarias +con el agregado diario de todas las campañas fco_ a partir de GACampaignMes. + +Ejecutar una sola vez: + python backfill_games_mayo.py +""" +import json +from airtable_client import AirtableClient + +at = AirtableClient() + +MES = 5 +ANIO = 2026 + +print("Cargando registros GACampaignMes de mayo (campañas fco_)...") +records = at.gacampaignmes.all( + formula="AND({Mes}='5',{Año}='2026')", + fields=["CampaignID", "MetricasDiarias", "Campaign Name (from CampaignID)"], +) + +campaigns_records = at.campaigns.all(fields=["CampaignID", "PPL"]) +at_id_to_info = { + r["id"]: { + "gid": str(r["fields"].get("CampaignID", "")).strip(), + "ppl": float(r["fields"].get("PPL", 0) or 0), + } + for r in campaigns_records +} + +# Agregar métricas por día para campañas fco_ +daily_agg: dict[str, dict] = {} + +for r in records: + at_cids = r["fields"].get("CampaignID", []) + if not at_cids: + continue + info = at_id_to_info.get(at_cids[0], {}) + ppl = info.get("ppl", 0) + + campaign_names = r["fields"].get("Campaign Name (from CampaignID)", []) + campaign_name = (campaign_names[0] if campaign_names else "").lower() + if not campaign_name.startswith("fco_"): + continue + + try: + md = json.loads(r["fields"].get("MetricasDiarias") or "{}") + except (json.JSONDecodeError, TypeError): + md = {} + + for day_str, vals in md.items(): + if day_str not in daily_agg: + daily_agg[day_str] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0} + daily_agg[day_str]["coste"] += vals.get("coste", 0) + daily_agg[day_str]["ingreso_sum"] += vals.get("ingreso", 0) + daily_agg[day_str]["ingreso_lxp"] += vals.get("leads_lake", 0) * ppl + daily_agg[day_str]["leads"] += int(vals.get("leads", 0)) + daily_agg[day_str]["leads_lake"] += int(vals.get("leads_lake", 0)) + +print(f" > Dias agregados: {sorted(daily_agg.keys())}") + +# Redondear +for d in daily_agg: + daily_agg[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in daily_agg[d].items()} + +print("Creando/obteniendo registro GAMes de mayo 2026...") +games_rid = at.get_or_create_games_record(MES, ANIO) +print(f" > Record ID: {games_rid}") + +print("Guardando MetricasDiarias en GAMes...") +at.update_games_metricas(games_rid, json.dumps(daily_agg, ensure_ascii=False)) + +print(f"OK Backfill GAMes completado: {len(daily_agg)} dias guardados.") diff --git a/run.py b/run.py index e9affa7..801a2d1 100644 --- a/run.py +++ b/run.py @@ -219,6 +219,7 @@ def run(): resumen = [] advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias + games_agg: dict = {} # dia_hoy → {coste, ingreso_sum, ingreso_lxp, leads, leads_lake} ayer = datetime.now() - timedelta(days=1) dia_hoy = ayer.strftime("%d") cambio_mes = ayer.month != datetime.now().month @@ -306,6 +307,15 @@ def run(): leads_lake_hoy = leads_yesterday.get(cid, 0) md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy, "leads": int(conv_hoy), "leads_lake": leads_lake_hoy} campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False) + + if campaign["curso"].lower().startswith("fco_"): + if dia_hoy not in games_agg: + games_agg[dia_hoy] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0} + games_agg[dia_hoy]["coste"] += coste_hoy + games_agg[dia_hoy]["ingreso_sum"] += ingreso_hoy + games_agg[dia_hoy]["ingreso_lxp"] += leads_lake_hoy * campaign["ppl"] + games_agg[dia_hoy]["leads"] += int(conv_hoy) + games_agg[dia_hoy]["leads_lake"] += leads_lake_hoy metricas_updates.append({ "airtable_id": campaign["airtable_id"], "metricas_json": json.dumps(md, ensure_ascii=False), @@ -355,6 +365,16 @@ def run(): at.batch_update_metricas_diarias(metricas_updates) print(" ✓ MetricasDiarias actualizado.") + # Actualizar GAMes con el agregado diario fco_ + if games_agg: + print("→ Actualizando GAMes...") + games_rid = at.get_or_create_games_record(ayer.month, ayer.year) + games_md = at.get_games_metricas(games_rid) + for d, vals in games_agg.items(): + games_md[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in vals.items()} + at.update_games_metricas(games_rid, json.dumps(games_md, ensure_ascii=False)) + print(" ✓ GAMes actualizado.") + # Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo # PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo) final_leads_data = [ diff --git a/slack_reporter.py b/slack_reporter.py index 4dceda6..e69f6e8 100644 --- a/slack_reporter.py +++ b/slack_reporter.py @@ -151,6 +151,42 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N worst_month = month_rows[:TOP_N] best_month = list(reversed(month_rows[-TOP_N:])) + # ── Tabla de márgenes diarios ───────────────────────────────────────────── + daily_totals: dict[int, dict] = {} + for item in fco: + ppl = item["campaign"].get("ppl", 0) + md = _parse_metricas(item["campaign"].get("metricas_diarias", "{}")) + for day_str, vals in md.items(): + try: + d = int(day_str) + except ValueError: + continue + if d not in daily_totals: + daily_totals[d] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0} + daily_totals[d]["coste"] += vals.get("coste", 0) + daily_totals[d]["ingreso_sum"] += vals.get("ingreso", 0) + daily_totals[d]["ingreso_lxp"] += vals.get("leads_lake", 0) * ppl + + margin_table_block = None + if daily_totals and not primer_dia_mes: + rows = ["Día Sumatorio LeadsxPPL"] + for d in sorted(daily_totals): + coste = daily_totals[d]["coste"] + ing_sum = daily_totals[d]["ingreso_sum"] + ing_lxp = daily_totals[d]["ingreso_lxp"] + pct_sum = round((ing_sum - coste) / ing_sum * 100, 1) if ing_sum > 0 else 0.0 + pct_lxp = round((ing_lxp - coste) / ing_lxp * 100, 1) if ing_lxp > 0 else 0.0 + s_sum = ("+" if pct_sum >= 0 else "") + f"{pct_sum:.1f}%" + s_lxp = ("+" if pct_lxp >= 0 else "") + f"{pct_lxp:.1f}%" + rows.append(f"{d:02d} {s_sum:>9} {s_lxp:>9}") + margin_table_block = { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"📅 *MÁRGENES POR DÍA — {MESES_ES[now.month].upper()}*\n```\n" + "\n".join(rows) + "\n```", + }, + } + # ── Alertas ────────────────────────────────────────────────────────────── alerts = [ r for r in month_rows @@ -232,6 +268,10 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N ) return "\n".join(lines) + if margin_table_block: + blocks.append({"type": "divider"}) + blocks.append(margin_table_block) + blocks.append({"type": "divider"}) blocks.append({ "type": "section",