Compare commits

..

2 Commits

Author SHA1 Message Date
94745cfda3 Fix month-change MetricasDiarias corruption and -0€ display in Slack report
- run.py: skip metricas update when campaign has no prior-month
  GACampaignMes record instead of silently writing into the new month
- slack_reporter.py: round before formatting so small negative margins
  don't render as "-0€"
2026-07-02 17:48:42 +02:00
bfd130578a Improve SEM dashboard: split Histórico tabs, shrink KPI metrics, add daily P&L chart
- Separate campaign-level daily metrics from the global portfolio view in
  the Histórico tab using nested sub-tabs instead of a single stacked section.
- Shrink st.metric font sizes globally so large KPI values render correctly.
- Add a daily evolution chart in Resumen showing Inversión, Ingreso,
  Margen (sumatorio) and Margen (PPL) aggregated across filtered campaigns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 17:43:55 +02:00
3 changed files with 103 additions and 4 deletions

View File

@ -1,6 +1,7 @@
"""Dashboard interactivo para leads-optimizer — Streamlit.""" """Dashboard interactivo para leads-optimizer — Streamlit."""
import streamlit as st import streamlit as st
import pandas as pd import pandas as pd
import altair as alt
import json import json
import subprocess import subprocess
import sys import sys
@ -20,6 +21,19 @@ st.set_page_config(
initial_sidebar_state="expanded", initial_sidebar_state="expanded",
) )
# Los KPI (st.metric) por defecto son demasiado grandes y se cortan en pantallas
# más estrechas o cuando el valor es largo (p.ej. "1.234,56€"). Los reducimos.
st.markdown(
"""
<style>
[data-testid="stMetricValue"] { font-size: 1.5rem; }
[data-testid="stMetricLabel"] { font-size: 0.8rem; }
[data-testid="stMetricDelta"] { font-size: 0.8rem; }
</style>
""",
unsafe_allow_html=True,
)
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
URGENCIA_ICON = { URGENCIA_ICON = {
@ -183,6 +197,48 @@ def _compute_analysis(row: dict, dia_actual: int, dias_mes: int) -> dict:
} }
def _daily_summary(rows: list[dict]) -> pd.DataFrame:
"""Agrega Inversión/Ingreso/Margen día a día sumando las MetricasDiarias
de todas las campañas dadas, más un margen alternativo calculado con el
PPL fijo de cada campaña en lugar del ingreso reportado."""
daily = {}
for r in rows:
metricas = r.get("metricas")
if not metricas:
continue
items = metricas.items() if isinstance(metricas, dict) else [
(m.get("fecha"), m) for m in metricas
]
for fecha, m in items:
if not fecha or not isinstance(m, dict):
continue
gasto = revenue = leads = None
for k, v in m.items():
kl = k.lower()
if gasto is None and ("coste" in kl or "cost" in kl):
gasto = float(v or 0)
elif revenue is None and ("ingres" in kl or "revenue" in kl):
revenue = float(v or 0)
elif leads is None and "lead" in kl:
leads = float(v or 0)
entry = daily.setdefault(fecha, {"gasto": 0.0, "revenue": 0.0, "revenue_ppl": 0.0})
entry["gasto"] += gasto or 0.0
entry["revenue"] += revenue or 0.0
entry["revenue_ppl"] += (leads or 0.0) * r["ppl"]
if not daily:
return pd.DataFrame()
df = pd.DataFrame([{"Fecha": f, **v} for f, v in sorted(daily.items())])
return pd.DataFrame({
"Fecha": df["Fecha"],
"Inversión": df["gasto"],
"Ingreso": df["revenue"],
"Margen (sumatorio)": df["revenue"] - df["gasto"],
"Margen (PPL)": df["revenue_ppl"] - df["gasto"],
})
# ── Sidebar ─────────────────────────────────────────────────────────────────── # ── Sidebar ───────────────────────────────────────────────────────────────────
st.sidebar.title("Leads Optimizer") st.sidebar.title("Leads Optimizer")
@ -277,6 +333,35 @@ with tab_resumen:
st.divider() st.divider()
# ── Evolución diaria del mes (inversión / ingreso / margen) ──────────────────
st.subheader("Evolución diaria del mes")
df_summary = _daily_summary(filtered)
if df_summary.empty:
st.info("Sin métricas diarias disponibles para las campañas filtradas.")
else:
df_long = df_summary.melt("Fecha", var_name="Serie", value_name="Valor")
serie_order = ["Inversión", "Ingreso", "Margen (sumatorio)", "Margen (PPL)"]
color_scale = alt.Scale(
domain=serie_order,
range=["#2a78d6", "#1baf7a", "#4a3aa7", "#eb6834"],
)
chart = (
alt.Chart(df_long)
.mark_line(point=True, strokeWidth=2)
.encode(
x=alt.X("Fecha:O", title=None),
y=alt.Y("Valor:Q", title=""),
color=alt.Color("Serie:N", scale=color_scale, sort=serie_order, legend=alt.Legend(title=None)),
tooltip=["Fecha", "Serie", alt.Tooltip("Valor:Q", format=",.2f")],
)
.properties(height=320)
)
st.altair_chart(chart, use_container_width=True)
st.caption("Margen (sumatorio) = Ingreso reportado Gasto · Margen (PPL) = Leads del día × PPL de campaña Gasto")
st.divider()
# ── Alertas activas ─────────────────────────────────────────────────────── # ── Alertas activas ───────────────────────────────────────────────────────
alerts = [] alerts = []
for r in filtered: for r in filtered:
@ -454,6 +539,9 @@ with tab_campanas:
# ════════════════════════════════════════════════════════════════════════════ # # ════════════════════════════════════════════════════════════════════════════ #
with tab_historico: with tab_historico:
hist_camp_tab, hist_portfolio_tab = st.tabs(["📋 Campañas", "🌐 Portfolio global"])
with hist_camp_tab:
st.subheader("Métricas diarias por campaña") st.subheader("Métricas diarias por campaña")
camp_names = [r["nombre"] for r in filtered if r["metricas"]] camp_names = [r["nombre"] for r in filtered if r["metricas"]]
@ -540,7 +628,7 @@ with tab_historico:
else: else:
st.info("Sin métricas diarias para esta campaña.") st.info("Sin métricas diarias para esta campaña.")
st.divider() with hist_portfolio_tab:
st.subheader("Portfolio agregado (GAMes)") st.subheader("Portfolio agregado (GAMes)")
@st.cache_data(ttl=300, show_spinner="Cargando portfolio...") @st.cache_data(ttl=300, show_spinner="Cargando portfolio...")

9
run.py
View File

@ -400,6 +400,8 @@ def run():
if cambio_mes: if cambio_mes:
# Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto # Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto
prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year) prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year)
redirected = []
omitidos = []
for u in metricas_updates: for u in metricas_updates:
gid = next( gid = next(
(item["campaign"]["google_campaign_id"] (item["campaign"]["google_campaign_id"]
@ -408,6 +410,13 @@ def run():
) )
if gid and gid in prev_map: if gid and gid in prev_map:
u["airtable_id"] = prev_map[gid] u["airtable_id"] = prev_map[gid]
redirected.append(u)
else:
omitidos.append(gid)
metricas_updates = redirected
if omitidos:
print(f" ⚠️ {len(omitidos)} campañas sin GACampaignMes en {ayer.month}/{ayer.year}, "
f"MetricasDiarias del día anterior omitido para no contaminar el mes nuevo: {omitidos}")
at.batch_update_metricas_diarias(metricas_updates) at.batch_update_metricas_diarias(metricas_updates)
print(" ✓ MetricasDiarias actualizado.") print(" ✓ MetricasDiarias actualizado.")

View File

@ -73,8 +73,9 @@ def _trunc(text: str, limit: int = 2950) -> str:
def _fmt_eur(v: float) -> str: def _fmt_eur(v: float) -> str:
sign = "+" if v > 0 else "" v_int = round(v)
return f"{sign}{v:,.0f}".replace(",", ".") sign = "+" if v_int > 0 else ""
return f"{sign}{v_int:,.0f}".replace(",", ".")
def _curso(name: str, max_len: int = 40) -> str: def _curso(name: str, max_len: int = 40) -> str:
@ -196,7 +197,8 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
return f"{v:,.0f}".replace(",", ".") return f"{v:,.0f}".replace(",", ".")
def _marg(v: float) -> str: def _marg(v: float) -> str:
return ("+" if v >= 0 else "") + f"{v:,.0f}".replace(",", ".") v_int = round(v)
return ("+" if v_int >= 0 else "") + f"{v_int:,.0f}".replace(",", ".")
def _pct(v: float) -> str: def _pct(v: float) -> str:
return ("+" if v >= 0 else "") + f"{v:.1f}%" return ("+" if v >= 0 else "") + f"{v:.1f}%"