From 3eec871d93da82833a1f1c48ac1c6628b17a2e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Thu, 30 Apr 2026 12:38:10 +0200 Subject: [PATCH] Add Slack summary report with daily metrics and campaign rankings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New slack_reporter.py: posts daily summary to Slack after each run - Shows monthly investment, revenue (sumatorio + leadsxPPL) and margins - Rankings of best/worst fco_ campaigns for last 5 days and current month - Alerts for campaigns with losses > 200€ or margin < -50% - MetricasDiarias uses yesterday's data (Google Ads lag) - SLACK_WEBHOOK_URL added to config Co-Authored-By: Claude Sonnet 4.6 --- config.py | 3 + requirements.txt | 1 + run.py | 7 ++ slack_reporter.py | 184 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 slack_reporter.py diff --git a/config.py b/config.py index 3cd5d81..810c0b0 100644 --- a/config.py +++ b/config.py @@ -19,5 +19,8 @@ GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] # Anthropic ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] +# Slack +SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "") + # OperaciΓ³n DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads diff --git a/requirements.txt b/requirements.txt index 9ea372a..11d5a33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ anthropic==0.95.0 pyairtable==3.3.0 google-ads==30.0.0 python-dotenv==1.2.2 +requests>=2.32.0 diff --git a/run.py b/run.py index 10feebb..ab7b1b6 100644 --- a/run.py +++ b/run.py @@ -10,6 +10,7 @@ from google_ads_client import GoogleAdsClient from analyzer import analyze from agent import decide from optimizer import apply_decision +from slack_reporter import build_and_send import config from datetime import datetime @@ -300,6 +301,7 @@ def run(): except (json.JSONDecodeError, TypeError): md = {} md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy} + campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False) metricas_updates.append({ "airtable_id": campaign["airtable_id"], "metricas_json": json.dumps(md, ensure_ascii=False), @@ -368,6 +370,11 @@ def run(): print(f" {'':35} πŸ’‘ {r['consejo']}") print() + # Enviar resumen a Slack + print("β†’ Enviando resumen a Slack...") + build_and_send(collected, config.DRY_RUN) + print(" βœ“ Resumen enviado a Slack.") + if __name__ == "__main__": timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") diff --git a/slack_reporter.py b/slack_reporter.py new file mode 100644 index 0000000..3bd4f85 --- /dev/null +++ b/slack_reporter.py @@ -0,0 +1,184 @@ +import json +import requests +import config +from datetime import datetime + + +ALERT_LOSS_EUR = 200 # pΓ©rdida absoluta > 200€ β†’ alerta +ALERT_MARGIN_PCT = -50 # margen % < -50% β†’ alerta +TOP_N = 5 # campaΓ±as a mostrar en rankings + + +def _parse_metricas(metricas_json: str) -> dict: + try: + return json.loads(metricas_json) if metricas_json else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def _last_n_days_sum(md: dict, n: int) -> dict: + days = sorted(md.keys())[-n:] + return { + "coste": round(sum(md[d].get("coste", 0) for d in days), 2), + "ingreso": round(sum(md[d].get("ingreso", 0) for d in days), 2), + "margen": round(sum(md[d].get("margen", 0) for d in days), 2), + "n_days": len(days), + } + + +def _fmt_eur(v: float) -> str: + sign = "+" if v > 0 else "" + return f"{sign}{v:,.0f}€".replace(",", ".") + + +def _curso(name: str, max_len: int = 42) -> str: + return name[:max_len] + ("…" if len(name) > max_len else "") + + +def build_and_send(collected: list, dry_run: bool) -> None: + if not config.SLACK_WEBHOOK_URL: + print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envΓ­o.") + return + + now = datetime.now() + + fco = [item for item in collected if item["campaign"]["curso"].lower().startswith("fco_")] + + # ── Totales del mes ────────────────────────────────────────────────────── + inv_total = round(sum(item["metrics"].get("cost", 0) for item in fco), 2) + + ing_sumatorio = round(sum( + sum(d.get("ingreso", 0) for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values()) + for item in fco + ), 2) + ing_leads_ppl = round(sum(item["analysis"]["revenue_estimado"] for item in fco), 2) + + margen_sumatorio = round(ing_sumatorio - inv_total, 2) + margen_leads_ppl = round(ing_leads_ppl - inv_total, 2) + pct_sumatorio = round(margen_sumatorio / ing_sumatorio * 100, 1) if ing_sumatorio > 0 else 0.0 + pct_leads_ppl = round(margen_leads_ppl / ing_leads_ppl * 100, 1) if ing_leads_ppl > 0 else 0.0 + + # ── Últimos 5 dΓ­as (desde MetricasDiarias) ─────────────────────────────── + last5_rows = [] + for item in fco: + md = _parse_metricas(item["campaign"].get("metricas_diarias", "{}")) + s = _last_n_days_sum(md, 5) + if s["n_days"] == 0: + continue + last5_rows.append({ + "curso": item["campaign"]["curso"], + "margen": s["margen"], + "ingreso": s["ingreso"], + "coste": s["coste"], + }) + last5_rows.sort(key=lambda x: x["margen"]) + worst_last5 = last5_rows[:TOP_N] + best_last5 = list(reversed(last5_rows[-TOP_N:])) + + # ── Mes en curso ───────────────────────────────────────────────────────── + month_rows = [] + for item in fco: + cost = item["metrics"].get("cost", 0) + rev = item["analysis"]["revenue_estimado"] + loss = round(rev - cost, 2) + pct = round(item["analysis"]["margen"] * 100, 1) + month_rows.append({ + "curso": item["campaign"]["curso"], + "margen": loss, + "margen_pct": pct, + "ingreso": round(rev, 2), + "coste": round(cost, 2), + }) + month_rows.sort(key=lambda x: x["margen"]) + worst_month = month_rows[:TOP_N] + best_month = list(reversed(month_rows[-TOP_N:])) + + # ── Alertas ────────────────────────────────────────────────────────────── + alerts = [ + r for r in month_rows + if r["margen"] < -ALERT_LOSS_EUR or r["margen_pct"] < ALERT_MARGIN_PCT + ] + alerts.sort(key=lambda x: x["margen"]) + + # ── Construir bloques ───────────────────────────────────────────────────── + mode = "πŸ”΅ DRY RUN" if dry_run else "⚑ PRODUCCIΓ“N" + + blocks = [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"LEADS OPTIMIZER β€” {now.strftime('%d/%m/%Y %H:%M')} {mode}"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"πŸ“Š *RESUMEN DEL MES*\n" + f"InversiΓ³n: *{inv_total:,.0f}€*\n" + f"Ingreso por sumatorio: *{ing_sumatorio:,.0f}€* | Margen por sumatorio: *{_fmt_eur(margen_sumatorio)}* ({pct_sumatorio}%)\n" + f"Ingreso LeadsxPPL: *{ing_leads_ppl:,.0f}€* | Margen por LeadsxPPL: *{_fmt_eur(margen_leads_ppl)}* ({pct_leads_ppl}%)" + ).replace(",", "."), + }, + }, + ] + + if alerts: + alert_lines = "\n".join( + f" πŸ”΄ `{_curso(a['curso'])}` β†’ PΓ©rdida *{_fmt_eur(a['margen'])}* ({a['margen_pct']}%)" + for a in alerts + ) + blocks.append({"type": "divider"}) + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🚨 *ALERTAS β€” CAMPAΓ‘AS CON PΓ‰RDIDAS IMPORTANTES*\n{alert_lines}", + }, + }) + + blocks.append({"type": "divider"}) + + def _ranking_text(rows, label, show_pct=False): + lines = [] + for i, r in enumerate(rows, 1): + pct_str = f" ({r['margen_pct']}%)" if show_pct else "" + lines.append(f" {i}. `{_curso(r['curso'])}` β†’ *{_fmt_eur(r['margen'])}*{pct_str}") + return f"{label}\n" + "\n".join(lines) + + blocks.append({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": _ranking_text(worst_last5, "πŸ“‰ *PEOR β€” ÚLTIMOS 5 DÍAS*"), + }, + { + "type": "mrkdwn", + "text": _ranking_text(best_last5, "πŸ“ˆ *MEJOR β€” ÚLTIMOS 5 DÍAS*"), + }, + ], + }) + + blocks.append({"type": "divider"}) + + blocks.append({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": _ranking_text(worst_month, "πŸ“‰ *PEOR β€” MES EN CURSO*", show_pct=True), + }, + { + "type": "mrkdwn", + "text": _ranking_text(best_month, "πŸ“ˆ *MEJOR β€” MES EN CURSO*", show_pct=True), + }, + ], + }) + + payload = {"blocks": blocks} + try: + resp = requests.post(config.SLACK_WEBHOOK_URL, json=payload, timeout=10) + if resp.status_code != 200: + print(f" ⚠️ Slack respondiΓ³ {resp.status_code}: {resp.text[:200]}") + except Exception as e: + print(f" ⚠️ Error enviando a Slack: {e}")