Add Slack summary report with daily metrics and campaign rankings
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
45e9515ae8
commit
3eec871d93
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
7
run.py
7
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")
|
||||
|
||||
184
slack_reporter.py
Normal file
184
slack_reporter.py
Normal file
@ -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}")
|
||||
Loading…
x
Reference in New Issue
Block a user