José Manuel Gómez bdc0d5ede3 Add GAMes table and daily margin table in Slack
- GAMes: new Airtable table aggregating daily fco_ metrics (coste, ingreso_sum, ingreso_lxp, leads, leads_lake)
- run.py: accumulate fco_ daily aggregate and write to GAMes each run
- slack_reporter.py: replace sparkline with daily margin % table (Sumatorio + LeadsxPPL per day)
- backfill_games_mayo.py: populated GAMes with all 17 existing May days

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:44:18 +02:00

425 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sys
import io
import os
import re
import json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
from airtable_client import AirtableClient
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, timedelta
class Tee:
"""Escribe simultáneamente en consola y en archivo de log."""
def __init__(self, filepath):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
self._file = open(filepath, "w", encoding="utf-8")
self._stdout = sys.stdout
def write(self, data):
self._stdout.write(data)
self._file.write(data)
def flush(self):
self._stdout.flush()
if not self._file.closed:
self._file.flush()
def close(self):
self._file.close()
ICONOS = {
"PAUSAR": "",
"SPRINT": "🚀",
"ACELERAR": "📈",
"FRENAR": "📉",
"EN_RITMO": "",
}
ACCION_ICONOS = {
"PAUSAR": "",
"AUMENTAR_PRESUPUESTO": "📈",
"REDUCIR_PRESUPUESTO": "📉",
"MANTENER": "",
}
# Google Ads customer ID (sin guiones) para construir enlaces directos
_CUSTOMER_ID = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID.replace("-", "")
def _ads_link(campaign_id: str) -> str:
return f"https://ads.google.com/aw/campaigns?campaignId={campaign_id}&__c={_CUSTOMER_ID}"
def _priority(item: dict) -> int:
"""
0 — Crítica: urgencia PAUSAR o SPRINT
1 — No crítica: accion cambia presupuesto pero urgencia no es crítica
2 — Mantener: accion MANTENER
"""
urgencia = item["analysis"]["urgencia"]
accion = item["decision"]["accion"]
if urgencia in ("PAUSAR", "SPRINT"):
return 0
if accion != "MANTENER":
return 1
return 2
SECCION_LABELS = {
0: "ACCIONES CRÍTICAS (PAUSAR / AUMENTAR)",
1: "ACCIONES NO CRÍTICAS",
2: "SIN CAMBIOS (MANTENER)",
}
CRITICIDAD_MAP = {
0: "Crítico",
1: "Peligro",
2: "Mantener",
}
def run():
print(f"\n{'='*55}")
print(f" LEADS OPTIMIZER — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f" Modo: {'DRY RUN (sin cambios)' if config.DRY_RUN else '⚡ PRODUCCIÓN'}")
print(f"{'='*55}\n")
at = AirtableClient()
gads = GoogleAdsClient()
# Sincronizar catálogo de campañas desde Google Ads → Airtable
print("→ Sincronizando campañas desde Google Ads...")
google_campaigns = gads.get_all_campaigns()
monthly_metrics = gads.get_monthly_metrics_all()
today_metrics = gads.get_yesterday_metrics_all()
_ayer_date = (datetime.now() - timedelta(days=1))
leads_yesterday = at.get_leads_by_campaign_on_date(_ayer_date.strftime("%Y-%m-%d"))
print(" Calculando PPL y CapTotalMes...")
ppl_lookup, cap_lookup = at.build_campaign_lookups()
sync_result = at.sync_campaigns_from_google_ads(google_campaigns, monthly_metrics, ppl_lookup, cap_lookup)
if sync_result["created"]:
print(f" ✅ Campañas nuevas importadas ({len(sync_result['created'])}):")
for c in sync_result["created"]:
print(f" + [{c['id']}] {c['name']}{c['status']}")
if sync_result["updated"]:
print(f" 🔄 Campañas actualizadas ({len(sync_result['updated'])}):")
for c in sync_result["updated"]:
for field, val in c["changes"].items():
print(f" ~ [{c['id']}] {c['name']} | {field}: '{val['antes']}''{val['ahora']}'")
if not sync_result["created"] and not sync_result["updated"]:
print(" ✓ Sin cambios en el catálogo.")
# Sincronizar GACampaignMes (campañas con actividad este mes)
print(" Sincronizando GACampaignMes...")
gcm_result = at.sync_gacampaignmes(
google_campaigns, monthly_metrics, ppl_lookup, cap_lookup,
sync_result["at_by_gid"]
)
print(f" ✓ GACampaignMes: {gcm_result['created']} nuevas, {gcm_result['updated']} actualizadas.")
print()
campaigns = at.get_active_gacampaignmes()
print(f"{len(campaigns)} campañas con actividad este mes")
print("→ Analizando...\n")
# Detección anticipada de cursos con Search Y PMX simultáneos
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
course_types_pre: dict[str, set] = {}
for c in campaigns:
num = _course_num(c["curso"])
if num:
course_types_pre.setdefault(num, set())
if "pmx" in c["curso"].lower():
course_types_pre[num].add("pmx")
elif "search" in c["curso"].lower():
course_types_pre[num].add("search")
courses_with_both = {
num for num, types in course_types_pre.items()
if "pmx" in types and "search" in types
}
# Detección anticipada de cursos con campaña _leadform activa
courses_with_leadform = {
_course_num(c["curso"])
for c in campaigns
if "_leadform" in c["curso"].lower() and _course_num(c["curso"])
}
# === PRIMERA PASADA: recopilar datos de todas las campañas ===
collected = []
skipped = []
status_updates = [] # (gcm_id, campaign_at_id, google_status) para batch update
for campaign in campaigns:
cid = campaign["google_campaign_id"]
leads, lead_ids = at.get_leads_this_month_gads(cid, campaign["curso"])
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
metrics = gads.get_campaign_metrics(cid)
if not metrics:
skipped.append(f"[{cid}] {campaign['curso']}")
continue
# Recopilar status para actualizar ambas tablas al final del pase
google_status = metrics.get("status", "")
if google_status:
status_updates.append((
campaign["airtable_id"],
campaign.get("campaign_at_id", ""),
google_status,
))
# Para PMX con companion Search: usar conversiones Google como leads de análisis
course_num = _course_num(campaign["curso"])
is_pmx_with_companion = (
"pmx" in campaign["curso"].lower()
and course_num in courses_with_both
)
leads_grupo = int(metrics.get("conversions", 0)) if is_pmx_with_companion else leads
analysis = analyze(campaign, leads_grupo, metrics)
decision = decide(analysis)
collected.append({
"campaign": campaign,
"leads": leads,
"leads_grupo": leads_grupo,
"metrics": metrics,
"analysis": analysis,
"decision": decision,
"today_metrics": today_metrics.get(cid, {}),
})
# Actualizar status en GACampaignMes y Google Ads Campaigns
if status_updates:
at.batch_update_status(status_updates)
if skipped:
print(f" ⚠️ {len(skipped)} campañas sin métricas omitidas:")
for s in skipped:
print(f" · {s}")
print()
# Ordenar: 0=críticas → 1=no críticas → 2=mantener
collected.sort(key=_priority)
# === SEGUNDA PASADA: imprimir en orden + aplicar decisiones ===
resumen = []
advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final
metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias
games_agg: dict = {} # dia_hoy → {coste, ingreso_sum, ingreso_lxp, leads, leads_lake}
ayer = datetime.now() - timedelta(days=1)
dia_hoy = ayer.strftime("%d")
cambio_mes = ayer.month != datetime.now().month
last_priority = -1
for item in collected:
campaign = item["campaign"]
leads = item["leads"]
metrics = item["metrics"]
analysis = item["analysis"]
decision = item["decision"]
today_m = item["today_metrics"]
cid = campaign["google_campaign_id"]
p = _priority(item)
if p != last_priority:
print(f"\n{''*55}")
print(f" {SECCION_LABELS[p]}")
print(f"{''*55}")
last_priority = p
icono = ICONOS.get(analysis["urgencia"], "")
accion_icono = ACCION_ICONOS.get(decision["accion"], "")
print(f"{''*55}")
print(f"📚 {campaign['curso']}")
print(f" ID: {cid} | PPL: {campaign['ppl']}€ | Cap: {campaign['capping_mensual']} leads")
print(f" 🔗 {_ads_link(cid)}")
print(f" Leads mes: {leads}/{campaign['capping_mensual']} "
f"({analysis['ratio_leads']*100:.0f}% cap) | "
f"Ratio mes: {analysis['ratio_mes']*100:.0f}%")
print(f" CPA actual: {analysis['cpa_actual']}€ | "
f"CPA máximo: {analysis['cpa_maximo']}€ | "
f"Margen: {analysis['margen']*100:.0f}%")
print(f" Urgencia: {icono} {analysis['urgencia']} | "
f"Rentable: {'' if analysis['rentable'] else ''}")
if analysis["alerta_tracking"]:
print(f" 🚨 ALERTA TRACKING: {analysis['discrepancia_tracking']} leads de diferencia "
f"entre Airtable ({campaign['conv_leads_lake_mes']}) y Google Ads ({int(analysis['conversiones_google'])})")
print(f" Decisión: {accion_icono} {decision['accion']} "
f"(confianza: {decision['confianza']*100:.0f}%)")
print(f" Justificación: {decision['justificacion']}")
if decision.get("consejo"):
print(f" 💡 Consejo: {decision['consejo']}")
if decision.get("alerta"):
print(f" 🚨 {decision['alerta']}")
apply_decision(campaign, decision, metrics, gads)
conv_google = int(analysis["conversiones_google"])
discrepancia_fresh = abs(conv_google - leads)
if discrepancia_fresh > 10:
log_text = (
f"⚠️ DISCREPANCIA: {leads} leads Airtable vs {conv_google} conv Google Ads "
f"{discrepancia_fresh})"
)
else:
log_text = f"OK: {leads} leads Airtable | {conv_google} conv Google Ads"
# Nota de reatribución PMX para campañas Search con companion PMX del mismo curso
course_num = _course_num(campaign["curso"])
if "search" in campaign["curso"].lower() and course_num in courses_with_both:
log_text += " | ⚠️ PMX ATTRIBUTION: campaña Search con companion PMX activo — parte de las conversiones de Google pueden estar reatribuidas a la campaña PMX"
# Nota leadform: los leads se capturan en Google sin pasar por la web
is_leadform = "_leadform" in campaign["curso"].lower()
if is_leadform:
log_text += " | LEADFORM: leads capturados directamente en Google (sin visitar la web) — no llegan a Airtable"
elif course_num in courses_with_leadform:
log_text += " | ⚠️ LEADFORM COMPANION: existe una campaña _leadform activa para este curso — parte de las conversiones de Google pueden provenir de leads capturados directamente en Google"
# Métricas diarias: coste hoy, ingreso (conversiones × PPL) y margen
coste_hoy = round(today_m.get("cost", 0), 2)
conv_hoy = today_m.get("conversions", 0)
ingreso_hoy = round(conv_hoy * campaign["ppl"], 2)
margen_hoy = round(ingreso_hoy - coste_hoy, 2)
try:
md = json.loads(campaign["metricas_diarias"])
except (json.JSONDecodeError, TypeError):
md = {}
leads_lake_hoy = leads_yesterday.get(cid, 0)
md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy, "leads": int(conv_hoy), "leads_lake": leads_lake_hoy}
campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False)
if campaign["curso"].lower().startswith("fco_"):
if dia_hoy not in games_agg:
games_agg[dia_hoy] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0}
games_agg[dia_hoy]["coste"] += coste_hoy
games_agg[dia_hoy]["ingreso_sum"] += ingreso_hoy
games_agg[dia_hoy]["ingreso_lxp"] += leads_lake_hoy * campaign["ppl"]
games_agg[dia_hoy]["leads"] += int(conv_hoy)
games_agg[dia_hoy]["leads_lake"] += leads_lake_hoy
metricas_updates.append({
"airtable_id": campaign["airtable_id"],
"metricas_json": json.dumps(md, ensure_ascii=False),
})
advice_updates.append((
campaign["airtable_id"],
decision.get("consejo", ""),
CRITICIDAD_MAP[p],
log_text,
))
resumen.append({
"curso": campaign["curso"],
"urgencia": analysis["urgencia"],
"accion": decision["accion"],
"leads": f"{leads}/{campaign['capping_mensual']}",
"cpa": analysis["cpa_actual"],
"margen": f"{analysis['margen']*100:.0f}%",
"consejo": decision.get("consejo", ""),
"link": _ads_link(cid),
"prioridad": p,
})
print()
# Guardar consejos y criticidad en GACampaignMes
if advice_updates:
print(f"→ Guardando consejos y criticidad en GACampaignMes ({len(advice_updates)} registros)...")
at.batch_update_gacampaignmes_advice(advice_updates)
print(" ✓ Consejos y criticidad guardados.")
# Guardar métricas diarias en MetricasDiarias
if metricas_updates:
print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...")
if cambio_mes:
# Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto
prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year)
for u in metricas_updates:
gid = next(
(item["campaign"]["google_campaign_id"]
for item in collected if item["campaign"]["airtable_id"] == u["airtable_id"]),
None,
)
if gid and gid in prev_map:
u["airtable_id"] = prev_map[gid]
at.batch_update_metricas_diarias(metricas_updates)
print(" ✓ MetricasDiarias actualizado.")
# Actualizar GAMes con el agregado diario fco_
if games_agg:
print("→ Actualizando GAMes...")
games_rid = at.get_or_create_games_record(ayer.month, ayer.year)
games_md = at.get_games_metricas(games_rid)
for d, vals in games_agg.items():
games_md[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in vals.items()}
at.update_games_metricas(games_rid, json.dumps(games_md, ensure_ascii=False))
print(" ✓ GAMes actualizado.")
# Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo
# PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo)
final_leads_data = [
{
"airtable_id": item["campaign"]["airtable_id"],
"conv_leads_lake_mes": item["leads"],
"conv_leads_lake_mes_grupo": item["leads_grupo"],
}
for item in collected
]
if final_leads_data:
print(f"→ Actualizando ConvLeadsLakeMesFinal ({len(final_leads_data)} registros)...")
at.batch_update_gacampaignmes_final_leads(final_leads_data)
print(" ✓ ConvLeadsLakeMesFinal actualizado.")
# Resumen final ordenado por prioridad (ya está ordenado)
print(f"{'='*55}")
print("RESUMEN FINAL")
print(f"{'='*55}")
last_p = -1
for r in resumen:
if r["prioridad"] != last_p:
print(f"\n --- {SECCION_LABELS[r['prioridad']]} ---")
last_p = r["prioridad"]
print(f" {r['curso'][:35]:<35} | {r['urgencia']:<12} | {r['accion']:<25} | {r['leads']} leads | {r['margen']} margen")
print(f" {'':35} {r['link']}")
if r["consejo"]:
print(f" {'':35} 💡 {r['consejo']}")
print()
# Enviar resumen a Slack
print("→ Enviando resumen a Slack...")
prev_month_metricas = at.get_metricas_diarias_prev_month() if (cambio_mes or datetime.now().day <= 5) else {}
build_and_send(collected, config.DRY_RUN, prev_month_metricas)
print(" ✓ Resumen enviado a Slack.")
if __name__ == "__main__":
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = os.path.join("logs", f"{timestamp}.log")
tee = Tee(log_path)
sys.stdout = tee
try:
run()
finally:
tee.close()
print(f"\nLog guardado en: {log_path}", file=tee._stdout)