import json import requests import config from datetime import datetime, timedelta, date 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 MESES_ES = { 1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril", 5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto", 9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre", } 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_combined(current_md: dict, prev_md: dict, now: datetime, n: int) -> dict: """ Combina MetricasDiarias del mes actual y del anterior para calcular los últimos n días. Las claves son strings de día ("01"…"31"); para ordenar por fecha real se añade el mes. """ entries = [] prev_month = now.month - 1 if now.month > 1 else 12 prev_year = now.year if now.month > 1 else now.year - 1 for day_str, vals in prev_md.items(): try: entries.append((date(prev_year, prev_month, int(day_str)), vals)) except ValueError: pass for day_str, vals in current_md.items(): try: entries.append((date(now.year, now.month, int(day_str)), vals)) except ValueError: pass entries.sort(key=lambda x: x[0]) last_n = entries[-n:] return { "coste": round(sum(v.get("coste", 0) for _, v in last_n), 2), "ingreso": round(sum(v.get("ingreso", 0) for _, v in last_n), 2), "margen": round(sum(v.get("margen", 0) for _, v in last_n), 2), "n_days": len(last_n), } def _fmt_eur(v: float) -> str: sign = "+" if v > 0 else "" return f"{sign}{v:,.0f}€".replace(",", ".") def _curso(name: str, max_len: int = 40) -> str: return name[:max_len] + ("…" if len(name) > max_len else "") def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None) -> None: if not config.SLACK_WEBHOOK_URL: print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.") return now = datetime.now() ayer = now - timedelta(days=1) cambio_mes = ayer.month != now.month mes_sumatorio = MESES_ES[ayer.month] if cambio_mes else None prev_md_map = prev_month_metricas or {} fco = [item for item in collected if item["campaign"]["curso"].lower().startswith("fco_")] primer_dia_mes = now.day == 1 # ── Totales del mes en curso ───────────────────────────────────────────── inv_total = round(sum(item["metrics"].get("cost", 0) for item in fco), 2) conv_total = int(sum(item["analysis"]["conversiones_google"] for item in fco)) ing_leads_ppl = round(sum(item["analysis"]["revenue_estimado"] for item in fco), 2) if primer_dia_mes: # Mes recién empezado: no hay datos de sumatorio para el mes en curso ing_sumatorio = 0.0 margen_sumatorio = 0.0 pct_sumatorio = 0.0 margen_leads_ppl = 0.0 pct_leads_ppl = 0.0 else: ing_sumatorio = 0.0 coste_sumatorio = 0.0 for item in fco: for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values(): ing_sumatorio += d.get("ingreso", 0) coste_sumatorio += d.get("coste", 0) ing_sumatorio = round(ing_sumatorio, 2) coste_sumatorio = round(coste_sumatorio, 2) margen_sumatorio = round(ing_sumatorio - coste_sumatorio, 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 # ── Totales del mes anterior (solo día 1) ──────────────────────────────── if primer_dia_mes and prev_md_map: # Iterar sobre TODAS las campañas del mes anterior filtrando fco_ # (no solo las activas en el mes en curso, para no perder campañas pausadas) prev_fco = [v for v in prev_md_map.values() if v.get("nombre", "").lower().startswith("fco_")] prev_inv = round(sum(v.get("coste_mes", 0) for v in prev_fco), 2) prev_conv = int(sum(v.get("conv_mes", 0) for v in prev_fco)) prev_ing = round(sum( sum(d.get("ingreso", 0) for d in _parse_metricas(v.get("metricas", "{}")).values()) for v in prev_fco ), 2) prev_margen = round(prev_ing - prev_inv, 2) prev_pct = round(prev_margen / prev_ing * 100, 1) if prev_ing > 0 else 0.0 else: prev_inv = prev_conv = prev_ing = prev_margen = prev_pct = None # ── Últimos 5 días (combinando meses si hay cambio de mes) ─────────────── last5_rows = [] for item in fco: gid = item["campaign"]["google_campaign_id"] current_md = _parse_metricas(item["campaign"].get("metricas_diarias", "{}")) prev_md = _parse_metricas(prev_md_map.get(gid, {}).get("metricas", "{}")) s = _last_n_days_combined(current_md, prev_md, now, 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"] month_rows.append({ "curso": item["campaign"]["curso"], "margen": round(rev - cost, 2), "margen_pct": round(item["analysis"]["margen"] * 100, 1), "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:])) # ── 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} daily_totals[d]["coste"] += vals.get("coste", 0) daily_totals[d]["ingreso_sum"] += vals.get("ingreso", 0) margin_table_block = None if daily_totals and not primer_dia_mes: rows = ["Día Margen € %"] total_coste = total_ing = 0.0 for d in sorted(daily_totals): coste = daily_totals[d]["coste"] ing_sum = daily_totals[d]["ingreso_sum"] margen = ing_sum - coste pct = round(margen / ing_sum * 100, 1) if ing_sum > 0 else 0.0 total_coste += coste total_ing += ing_sum s_eur = ("+" if margen >= 0 else "") + f"{margen:,.0f}€".replace(",", ".") s_pct = ("+" if pct >= 0 else "") + f"{pct:.1f}%" rows.append(f"{d:02d} {s_eur:>9} {s_pct:>7}") total_margen = total_ing - total_coste total_pct = round(total_margen / total_ing * 100, 1) if total_ing > 0 else 0.0 s_tot_eur = ("+" if total_margen >= 0 else "") + f"{total_margen:,.0f}€".replace(",", ".") s_tot_pct = ("+" if total_pct >= 0 else "") + f"{total_pct:.1f}%" rows.append("─" * 24) rows.append(f"TOT {s_tot_eur:>9} {s_tot_pct:>7}") 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 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 EN CURSO ({MESES_ES[now.month]})*\n" f"Inversión: *{inv_total:,.0f}€* | Conversiones: *{conv_total}*\n" + ( f"Ingreso por sumatorio: *0€* | Margen por sumatorio: *0€*\n" f"Ingreso LeadsxPPL: *0€* | Margen por LeadsxPPL: *0€*" if primer_dia_mes else 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 primer_dia_mes and prev_inv is not None: blocks.append({"type": "divider"}) blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": ( f"📅 *CIERRE {MESES_ES[ayer.month].upper()} (mes anterior)*\n" f"_Datos de Google Ads_\n" f"Inversión: *{prev_inv:,.0f}€* | Conversiones: *{prev_conv}*\n" f"Ingreso por sumatorio: *{prev_ing:,.0f}€* | Margen: *{_fmt_eur(prev_margen)}* ({prev_pct}%)" ).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}", }, }) def _ranking_last5_text(rows, label): lines = [label] for i, r in enumerate(rows, 1): lines.append(f" {i}. `{_curso(r['curso'])}`") lines.append( f" Coste: {r['coste']:,.0f}€ | Ingreso: {r['ingreso']:,.0f}€ | Margen: *{_fmt_eur(r['margen'])}*" .replace(",", ".") ) return "\n".join(lines) def _ranking_month_text(rows, label): lines = [label] for i, r in enumerate(rows, 1): lines.append( f" {i}. `{_curso(r['curso'])}` → *{_fmt_eur(r['margen'])}* ({r['margen_pct']}%)" ) 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", "text": { "type": "mrkdwn", "text": _ranking_last5_text(worst_last5, "📉 *PEOR — ÚLTIMOS 5 DÍAS*"), }, }) blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": _ranking_last5_text(best_last5, "📈 *MEJOR — ÚLTIMOS 5 DÍAS*"), }, }) if not primer_dia_mes: blocks.append({"type": "divider"}) blocks.append({ "type": "section", "fields": [ { "type": "mrkdwn", "text": _ranking_month_text(worst_month, "📉 *PEOR — MES EN CURSO*"), }, { "type": "mrkdwn", "text": _ranking_month_text(best_month, "📈 *MEJOR — MES EN CURSO*"), }, ], }) 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}")