Compare commits

..

3 Commits

Author SHA1 Message Date
a7f8ddad08 Add backfill scripts for MetricasDiarias gaps caused by the month-change bug
Some checks failed
Weekly Strategic Report / run (push) Has been cancelled
One-off scripts to reconstruct missing daily coste/ingreso/margen/leads
from Google Ads and Leads Lake for days lost to the overwrite bug fixed
in run.py. backfill_metricas_mes.py generalizes the approach (mes/año
args) and was used to repair both mayo and junio 2026; the two junio-
specific scripts document the narrower fixes applied first.
2026-07-03 00:43:41 +02:00
31193c57ee Fix month-change MetricasDiarias overwrite bug, add per-date Ads metrics helpers
On month change, run.py redirected the write to the previous month's
GACampaignMes record but built the JSON from the new month's near-empty
record, overwriting the previous month's full daily history with just
the redirected day. Now it merges with the previous month's real
MetricasDiarias (via get_metricas_diarias_prev_month) before writing.

Also add get_metrics_for_date() and get_daily_metrics_for_month() to
GoogleAdsClient for reconstructing historical daily cost/conversions,
needed by the backfill scripts.
2026-07-03 00:43:33 +02:00
ea201ef565 Replace ambiguous cost-vs-revenue bar charts with clearer line charts
Stacked/grouped bars made it hard to compare gasto vs ingreso when the
two values are close, and margen barely visible against that scale.
Extract a shared _eur_line_chart helper (single €-axis, fixed categorical
colors, tooltip) and reuse it for the per-campaign, portfolio, and
resumen daily views instead of duplicating Altair boilerplate per chart.
2026-07-03 00:43:21 +02:00
6 changed files with 538 additions and 34 deletions

View File

@ -0,0 +1,127 @@
"""
Reconstruye el día 30/06/2026 en MetricasDiarias para las GACampaignMes de
junio a las que les falta esa clave.
Motivo: en el cambio de mes (1 julio), run.py leía MetricasDiarias del
registro de julio (recién creado, casi vacío) en vez del de junio, así que
al redirigir la escritura hacia el registro de junio correcto se
sobrescribía con un dict casi vacío (perdiendo el día 30 real, que en
algunos casos quedó mal grabado en el registro de julio). Aquí se recalcula
el día 30 desde Google Ads (coste, conversiones) y Leads Lake, y se añade
SOLO a los registros de junio que no tienen ya esa clave no se toca nada
más de su histórico.
Uso:
python backfill_metricas_30junio.py # dry run — solo muestra
python backfill_metricas_30junio.py --apply # escribe en Airtable
"""
import json
import re
import sys
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
DATE_STR = "2026-06-30"
DAY_KEY = "30"
MES, ANIO = 6, 2026
def _course_num(name: str) -> str | None:
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
return m.group(1) if m else None
def run(apply: bool):
at = AirtableClient()
gads = GoogleAdsClient()
formula = f"AND({{Mes}}='{MES}',{{Año}}='{ANIO}')"
records = at.gacampaignmes.all(
formula=formula,
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
)
campaigns_records = at.campaigns.all(fields=["CampaignID"])
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
campaigns = []
for r in records:
f = r["fields"]
at_cids = f.get("CampaignID", [])
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
if not gid:
continue
name_list = f.get("Campaign Name (from CampaignID)", [])
campaigns.append({
"airtable_id": r["id"],
"google_campaign_id": gid,
"curso": name_list[0] if name_list else "Sin nombre",
"ppl": float(f.get("PPL") or 0),
"metricas_diarias": f.get("MetricasDiarias") or "{}",
})
print(f"{len(campaigns)} campañas de {MES}/{ANIO} en GACampaignMes")
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
cursoid_to_campaign: dict[str, str] = {}
for c in campaigns:
num = _course_num(c["curso"])
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
cursoid_to_campaign[num] = c["google_campaign_id"]
# Detectar registros a los que YA les falta el día 30
targets = []
for c in campaigns:
try:
md = json.loads(c["metricas_diarias"])
except (json.JSONDecodeError, TypeError):
md = {}
if DAY_KEY not in md:
c["md"] = md
targets.append(c)
print(f"{len(targets)} campañas sin el día {DAY_KEY} en su MetricasDiarias\n")
if not targets:
print("Nada que hacer.")
return
ads_metrics = gads.get_metrics_for_date(DATE_STR)
leads_lake_counts = at.get_leads_by_campaign_on_date(DATE_STR, cursoid_to_campaign)
metricas_updates = []
print(f"{'Campaña':45} {'Coste':>10} {'Conv':>6} {'Ingreso':>10} {'Margen':>10} {'LeadsLake':>10}")
for c in targets:
cid = c["google_campaign_id"]
today_m = ads_metrics.get(cid, {})
coste_hoy = round(today_m.get("cost", 0), 2)
conv_hoy = today_m.get("conversions", 0)
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
leads_lake_hoy = leads_lake_counts.get(cid, 0)
print(f"{c['curso'][:45]:45} {coste_hoy:>9.2f}{conv_hoy:>6.0f} "
f"{ingreso_hoy:>9.2f}{margen_hoy:>9.2f}{leads_lake_hoy:>10}")
md = dict(c["md"])
md[DAY_KEY] = {
"coste": coste_hoy,
"ingreso": ingreso_hoy,
"margen": margen_hoy,
"leads": int(conv_hoy),
"leads_lake": leads_lake_hoy,
}
metricas_updates.append({
"airtable_id": c["airtable_id"],
"metricas_json": json.dumps(md, ensure_ascii=False),
})
print()
if apply:
at.batch_update_metricas_diarias(metricas_updates)
print(f"{len(metricas_updates)} registros actualizados en Airtable.")
else:
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
f"Ejecuta con --apply para escribir en Airtable.")
if __name__ == "__main__":
run(apply="--apply" in sys.argv)

View File

@ -0,0 +1,142 @@
"""
Reconstruye los huecos en MetricasDiarias de las GACampaignMes de junio 2026:
- 35 campañas perdieron los días 01-29 (el bug de cambio de mes sobrescribió
su histórico completo dejando solo el día 30).
- 32 campañas no tienen el día 30 (nunca se llegó a escribir coste y
conversiones fueron 0 ese día, confirmado contra Google Ads).
Recalcula cada día que falte a partir de Google Ads (coste, conversiones) y
Leads Lake (leads_lake), exactamente igual que lo habría hecho run.py cada
día, y hace merge con lo que ya existe en cada registro no toca los días
que ya están bien.
Uso:
python backfill_metricas_junio_completo.py # dry run
python backfill_metricas_junio_completo.py --apply # escribe en Airtable
"""
import json
import re
import sys
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
MES, ANIO = 6, 2026
FULL_MONTH_DAYS = [str(d).zfill(2) for d in range(1, 31)]
def _course_num(name: str) -> str | None:
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
return m.group(1) if m else None
def run(apply: bool):
at = AirtableClient()
gads = GoogleAdsClient()
formula = f"AND({{Mes}}='{MES}',{{Año}}='{ANIO}')"
records = at.gacampaignmes.all(
formula=formula,
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
)
campaigns_records = at.campaigns.all(fields=["CampaignID"])
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
campaigns = []
for r in records:
f = r["fields"]
at_cids = f.get("CampaignID", [])
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
if not gid:
continue
name_list = f.get("Campaign Name (from CampaignID)", [])
try:
md = json.loads(f.get("MetricasDiarias") or "{}")
except (json.JSONDecodeError, TypeError):
md = {}
campaigns.append({
"airtable_id": r["id"],
"google_campaign_id": gid,
"curso": name_list[0] if name_list else "Sin nombre",
"ppl": float(f.get("PPL") or 0),
"md": md,
})
print(f"{len(campaigns)} campañas de {MES}/{ANIO} en GACampaignMes")
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
cursoid_to_campaign: dict[str, str] = {}
for c in campaigns:
num = _course_num(c["curso"])
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
cursoid_to_campaign[num] = c["google_campaign_id"]
# Días que le faltan a cada campaña
for c in campaigns:
c["missing"] = [d for d in FULL_MONTH_DAYS if d not in c["md"]]
targets = [c for c in campaigns if c["missing"]]
all_missing_dates = sorted({f"{ANIO}-{MES:02d}-{d}" for c in targets for d in c["missing"]})
print(f"{len(targets)} campañas con días faltantes ({len(all_missing_dates)} fechas distintas a consultar)\n")
if not targets:
print("Nada que hacer.")
return
print("→ Consultando Google Ads (coste + conversiones diarias de junio)...")
daily_metrics = gads.get_daily_metrics_for_month(ANIO, MES)
print("→ Consultando Leads Lake por fecha...")
leads_lake_by_date = {}
for date_str in all_missing_dates:
leads_lake_by_date[date_str] = at.get_leads_by_campaign_on_date(date_str, cursoid_to_campaign)
print()
metricas_updates = []
total_coste = total_ingreso = 0.0
print(f"{'Campaña':45} {'Días':>5} {'Coste rec.':>11} {'Ingreso rec.':>13} {'Margen rec.':>12}")
for c in targets:
cid = c["google_campaign_id"]
md = dict(c["md"])
camp_coste = camp_ingreso = 0.0
for d in c["missing"]:
date_str = f"{ANIO}-{MES:02d}-{d}"
m = daily_metrics.get(date_str, {}).get(cid, {})
coste_hoy = round(m.get("cost", 0), 2)
conv_hoy = m.get("conversions", 0)
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
leads_lake_hoy = leads_lake_by_date.get(date_str, {}).get(cid, 0)
md[d] = {
"coste": coste_hoy,
"ingreso": ingreso_hoy,
"margen": margen_hoy,
"leads": int(conv_hoy),
"leads_lake": leads_lake_hoy,
}
camp_coste += coste_hoy
camp_ingreso += ingreso_hoy
total_coste += camp_coste
total_ingreso += camp_ingreso
print(f"{c['curso'][:45]:45} {len(c['missing']):>5} {camp_coste:>10.2f}"
f"{camp_ingreso:>12.2f}{camp_ingreso - camp_coste:>11.2f}")
metricas_updates.append({
"airtable_id": c["airtable_id"],
"metricas_json": json.dumps(md, ensure_ascii=False, sort_keys=True),
})
print(f"\n{'TOTAL recuperado':45} {'':>5} {total_coste:>10.2f}{total_ingreso:>12.2f}"
f"{total_ingreso - total_coste:>11.2f}")
print()
if apply:
at.batch_update_metricas_diarias(metricas_updates)
print(f"{len(metricas_updates)} registros actualizados en Airtable.")
else:
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
f"Ejecuta con --apply para escribir en Airtable.")
if __name__ == "__main__":
run(apply="--apply" in sys.argv)

149
backfill_metricas_mes.py Normal file
View File

@ -0,0 +1,149 @@
"""
Reconstruye los huecos en MetricasDiarias de las GACampaignMes de un mes dado.
Motivo: el bug de cambio de mes en run.py (ver commit que añade merge con
get_metricas_diarias_prev_month) sobrescribía el histórico del mes saliente
con un dict casi vacío al redirigir la escritura del último día. Esto pasó en
todos los cambios de mes hasta que se arregló, dejando huecos a veces todo
el mes salvo el último día, a veces solo el último día, a veces huecos
sueltos si Airtable falló algún día concreto.
Recalcula cada día que falte a partir de Google Ads (coste, conversiones) y
Leads Lake (leads_lake), exactamente igual que lo habría hecho run.py cada
día, y hace merge con lo que ya existe en cada registro no toca ningún día
que ya esté guardado.
Uso:
python backfill_metricas_mes.py <mes> <año> # dry run
python backfill_metricas_mes.py <mes> <año> --apply # escribe en Airtable
"""
import calendar
import json
import re
import sys
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
def _course_num(name: str) -> str | None:
m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE)
return m.group(1) if m else None
def run(mes: int, anio: int, apply: bool):
at = AirtableClient()
gads = GoogleAdsClient()
days_in_month = calendar.monthrange(anio, mes)[1]
full_month_days = [str(d).zfill(2) for d in range(1, days_in_month + 1)]
formula = f"AND({{Mes}}='{mes}',{{Año}}='{anio}')"
records = at.gacampaignmes.all(
formula=formula,
fields=["CampaignID", "PPL", "MetricasDiarias", "Campaign Name (from CampaignID)"],
)
campaigns_records = at.campaigns.all(fields=["CampaignID"])
at_id_to_gid = {r["id"]: str(r["fields"].get("CampaignID", "")).strip() for r in campaigns_records}
campaigns = []
for r in records:
f = r["fields"]
at_cids = f.get("CampaignID", [])
gid = at_id_to_gid.get(at_cids[0], "") if at_cids else ""
if not gid:
continue
name_list = f.get("Campaign Name (from CampaignID)", [])
try:
md = json.loads(f.get("MetricasDiarias") or "{}")
except (json.JSONDecodeError, TypeError):
md = {}
campaigns.append({
"airtable_id": r["id"],
"google_campaign_id": gid,
"curso": name_list[0] if name_list else "Sin nombre",
"ppl": float(f.get("PPL") or 0),
"md": md,
})
print(f"{len(campaigns)} campañas de {mes}/{anio} en GACampaignMes")
# Mapping cursoid → PMX campaign_id, igual que run.py, para atribuir leadforms
cursoid_to_campaign: dict[str, str] = {}
for c in campaigns:
num = _course_num(c["curso"])
if num and "pmx" in c["curso"].lower() and "_leadform" not in c["curso"].lower():
cursoid_to_campaign[num] = c["google_campaign_id"]
# Días que le faltan a cada campaña
for c in campaigns:
c["missing"] = [d for d in full_month_days if d not in c["md"]]
targets = [c for c in campaigns if c["missing"]]
all_missing_dates = sorted({f"{anio}-{mes:02d}-{d}" for c in targets for d in c["missing"]})
print(f"{len(targets)} campañas con días faltantes ({len(all_missing_dates)} fechas distintas a consultar)\n")
if not targets:
print("Nada que hacer.")
return
print(f"→ Consultando Google Ads (coste + conversiones diarias de {mes}/{anio})...")
daily_metrics = gads.get_daily_metrics_for_month(anio, mes)
print("→ Consultando Leads Lake por fecha...")
leads_lake_by_date = {}
for date_str in all_missing_dates:
leads_lake_by_date[date_str] = at.get_leads_by_campaign_on_date(date_str, cursoid_to_campaign)
print()
metricas_updates = []
total_coste = total_ingreso = 0.0
print(f"{'Campaña':45} {'Días':>5} {'Coste rec.':>11} {'Ingreso rec.':>13} {'Margen rec.':>12}")
for c in targets:
cid = c["google_campaign_id"]
md = dict(c["md"])
camp_coste = camp_ingreso = 0.0
for d in c["missing"]:
date_str = f"{anio}-{mes:02d}-{d}"
m = daily_metrics.get(date_str, {}).get(cid, {})
coste_hoy = round(m.get("cost", 0), 2)
conv_hoy = m.get("conversions", 0)
ingreso_hoy = round(conv_hoy * c["ppl"], 2)
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
leads_lake_hoy = leads_lake_by_date.get(date_str, {}).get(cid, 0)
md[d] = {
"coste": coste_hoy,
"ingreso": ingreso_hoy,
"margen": margen_hoy,
"leads": int(conv_hoy),
"leads_lake": leads_lake_hoy,
}
camp_coste += coste_hoy
camp_ingreso += ingreso_hoy
total_coste += camp_coste
total_ingreso += camp_ingreso
print(f"{c['curso'][:45]:45} {len(c['missing']):>5} {camp_coste:>10.2f}"
f"{camp_ingreso:>12.2f}{camp_ingreso - camp_coste:>11.2f}")
metricas_updates.append({
"airtable_id": c["airtable_id"],
"metricas_json": json.dumps(md, ensure_ascii=False, sort_keys=True),
})
print(f"\n{'TOTAL recuperado':45} {'':>5} {total_coste:>10.2f}{total_ingreso:>12.2f}"
f"{total_ingreso - total_coste:>11.2f}")
print()
if apply:
at.batch_update_metricas_diarias(metricas_updates)
print(f"{len(metricas_updates)} registros actualizados en Airtable.")
else:
print(f"DRY RUN — {len(metricas_updates)} registros se actualizarían. "
f"Ejecuta con --apply para escribir en Airtable.")
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if len(args) != 2:
print("Uso: python backfill_metricas_mes.py <mes> <año> [--apply]")
sys.exit(1)
run(mes=int(args[0]), anio=int(args[1]), apply="--apply" in sys.argv)

View File

@ -197,6 +197,39 @@ def _compute_analysis(row: dict, dia_actual: int, dias_mes: int) -> dict:
} }
_SERIE_COLORS = {
"Inversión": "#2a78d6",
"Gasto": "#2a78d6",
"Ingreso": "#1baf7a",
"Revenue": "#1baf7a",
"Margen": "#4a3aa7",
"Margen (sumatorio)": "#4a3aa7",
"Margen (PPL)": "#eb6834",
}
def _eur_line_chart(df: pd.DataFrame, series: list[str], height: int = 320):
"""Gráfico de líneas en € para comparar varias series por día. Un solo eje
(todas las series están en ), colores categóricos fijos y tooltip evita
la ambigüedad de un gráfico de barras apiladas/agrupadas para comparar
magnitudes que pueden ser muy parecidas entre (p.ej. gasto vs ingreso)."""
series = [s for s in series if s in df.columns]
df_long = df.melt("Fecha", value_vars=series, var_name="Serie", value_name="Valor")
color_scale = alt.Scale(domain=series, range=[_SERIE_COLORS[s] for s in series])
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=series, legend=alt.Legend(title=None)),
tooltip=["Fecha", "Serie", alt.Tooltip("Valor:Q", format=",.2f")],
)
.properties(height=height)
)
st.altair_chart(chart, width="stretch")
def _daily_summary(rows: list[dict]) -> pd.DataFrame: def _daily_summary(rows: list[dict]) -> pd.DataFrame:
"""Agrega Inversión/Ingreso/Margen día a día sumando las MetricasDiarias """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 de todas las campañas dadas, más un margen alternativo calculado con el
@ -340,24 +373,7 @@ with tab_resumen:
if df_summary.empty: if df_summary.empty:
st.info("Sin métricas diarias disponibles para las campañas filtradas.") st.info("Sin métricas diarias disponibles para las campañas filtradas.")
else: else:
df_long = df_summary.melt("Fecha", var_name="Serie", value_name="Valor") _eur_line_chart(df_summary, ["Inversión", "Ingreso", "Margen (sumatorio)", "Margen (PPL)"])
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, width="stretch")
st.caption("Margen (sumatorio) = Ingreso reportado Gasto · Margen (PPL) = Leads del día × PPL de campaña Gasto") st.caption("Margen (sumatorio) = Ingreso reportado Gasto · Margen (PPL) = Leads del día × PPL de campaña Gasto")
st.divider() st.divider()
@ -610,14 +626,11 @@ with hist_camp_tab:
df_ritmo = df_ritmo.set_index("Fecha") df_ritmo = df_ritmo.set_index("Fecha")
st.line_chart(df_ritmo[["Leads acum.", "Objetivo"]]) st.line_chart(df_ritmo[["Leads acum.", "Objetivo"]])
# Gráfico gasto vs revenue # Gráfico gasto / ingreso / margen diario
numeric_cols = [c for c in ["Gasto", "Revenue"] if c in df_daily.columns] numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in df_daily.columns]
if numeric_cols: if numeric_cols and "Fecha" in df_daily.columns:
st.markdown("**Gasto vs Revenue diario**") st.markdown("**Gasto, ingreso y margen diario**")
chart_data = df_daily[numeric_cols].copy() _eur_line_chart(df_daily.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
if "Fecha" in df_daily.columns:
chart_data.index = df_daily["Fecha"]
st.bar_chart(chart_data)
# Tabla detalle # Tabla detalle
st.markdown("**Detalle diario**") st.markdown("**Detalle diario**")
@ -680,13 +693,10 @@ with hist_portfolio_tab:
col_map[c] = "Fecha" col_map[c] = "Fecha"
gdf = gdf.rename(columns=col_map) gdf = gdf.rename(columns=col_map)
numeric_cols = [c for c in ["Gasto", "Revenue"] if c in gdf.columns] numeric_cols = [c for c in ["Gasto", "Revenue", "Margen"] if c in gdf.columns]
if numeric_cols: if numeric_cols and "Fecha" in gdf.columns:
st.markdown("**Portfolio: Gasto vs Revenue diario**") st.markdown("**Portfolio: gasto, ingreso y margen diario**")
chart_data = gdf[numeric_cols].copy() _eur_line_chart(gdf.rename(columns={"Revenue": "Ingreso"}), ["Gasto", "Ingreso", "Margen"])
if "Fecha" in gdf.columns:
chart_data.index = gdf["Fecha"]
st.bar_chart(chart_data)
else: else:
st.info("Sin datos de portfolio para el período seleccionado.") st.info("Sin datos de portfolio para el período seleccionado.")

View File

@ -100,6 +100,70 @@ class GoogleAdsClient:
print(f" ❌ Error obteniendo métricas de hoy: {e}") print(f" ❌ Error obteniendo métricas de hoy: {e}")
return result return result
def get_metrics_for_date(self, date_str: str) -> dict:
"""
Devuelve métricas de una fecha concreta ('YYYY-MM-DD') para TODAS las
campañas en una sola query. Retorna dict {campaign_id: {conversions, cost}}.
"""
ga_service = self.client.get_service("GoogleAdsService")
query = f"""
SELECT
campaign.id,
metrics.conversions,
metrics.cost_micros
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date = '{date_str}'
"""
result = {}
try:
response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response:
cid = str(row.campaign.id)
result[cid] = {
"conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000,
}
except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas del {date_str}: {e}")
return result
def get_daily_metrics_for_month(self, year: int, month: int) -> dict:
"""
Devuelve coste y conversiones diarias de todas las campañas para un mes
completo. Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: {cost, conversions}}}.
"""
start = f"{year}-{month:02d}-01"
if month == 12:
end = f"{year + 1}-01-01"
else:
end = f"{year}-{month + 1:02d}-01"
ga_service = self.client.get_service("GoogleAdsService")
query = f"""
SELECT
campaign.id,
segments.date,
metrics.conversions,
metrics.cost_micros
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date >= '{start}'
AND segments.date < '{end}'
"""
result: dict = {}
try:
response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response:
date_str = row.segments.date
cid = str(row.campaign.id)
result.setdefault(date_str, {})[cid] = {
"conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000,
}
except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas diarias del mes: {e}")
return result
def get_daily_conversions_for_month(self, year: int, month: int) -> dict: def get_daily_conversions_for_month(self, year: int, month: int) -> dict:
""" """
Devuelve conversiones diarias de todas las campañas para un mes completo. Devuelve conversiones diarias de todas las campañas para un mes completo.

14
run.py
View File

@ -398,8 +398,13 @@ def run():
if metricas_updates: if metricas_updates:
print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...") print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...")
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.
# El "campaign" en memoria es el del mes nuevo (recién creado, MetricasDiarias
# casi vacío) — hay que hacer merge con el MetricasDiarias REAL del mes anterior
# antes de escribir, o se pierde el resto de días ya acumulados (bug que borró
# el histórico de junio salvo el día 30 al hacer un overwrite directo).
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)
prev_metricas = at.get_metricas_diarias_prev_month()
redirected = [] redirected = []
omitidos = [] omitidos = []
for u in metricas_updates: for u in metricas_updates:
@ -409,7 +414,14 @@ def run():
None, None,
) )
if gid and gid in prev_map: if gid and gid in prev_map:
try:
existing_md = json.loads(prev_metricas.get(gid, {}).get("metricas") or "{}")
except (json.JSONDecodeError, TypeError):
existing_md = {}
nuevo_dia_md = json.loads(u["metricas_json"])
existing_md.update(nuevo_dia_md)
u["airtable_id"] = prev_map[gid] u["airtable_id"] = prev_map[gid]
u["metricas_json"] = json.dumps(existing_md, ensure_ascii=False)
redirected.append(u) redirected.append(u)
else: else:
omitidos.append(gid) omitidos.append(gid)