Add GACampaignMes sync and use it as optimization source

- New GACampaignMes table tracks monthly state of campaigns with activity
  (ENABLED status or cost > 0 this month)
- sync_gacampaignmes(): creates/updates records with PPL, CPAMax, CosteMes,
  ConvMes, CapTotalMes, Status for current month (numeric Mes: 1-12)
- get_active_gacampaignmes(): reads GACampaignMes as source for optimization loop
- get_leads_this_month_gads(): filters leads by GoogleCampaignID + utm_source
  (pmx/google) + utm_medium (paid_search) instead of attr_cursoid
- update_gacampaignmes_leads_lake(): links leads to GACampaignMes Leads Lake field
- batch_update_gacampaignmes_consejos(): saves agent advice to Consejo field
- Google Ads Campaigns table simplified to catalog only (name, status, PPL)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-04-21 13:00:34 +02:00
parent 7344cc0c52
commit a5ce85ed32
2 changed files with 249 additions and 63 deletions

View File

@ -11,6 +11,7 @@ class AirtableClient:
self.cursos = self.api.table(config.AIRTABLE_BASE_ID, "Cursos")
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes")
MESES_ES = {
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
@ -22,7 +23,7 @@ class AirtableClient:
"""
Construye en 3 llamadas bulk:
ppl_lookup: {cursoid_text: ppl_ponderado}
PPL = Σ (PPL_centrocurso × % invalidación_centro / 100)
PPL = Σ (PPL_centrocurso × % invalidación_centro)
solo registros CentroCurso con Estado ROI = 'ABIERTO'
cap_lookup: {cursoid_text: caping_admitido}
desde CursoMes para el mes/año en curso; 0 si no existe
@ -58,8 +59,7 @@ class AirtableClient:
curso_by_recordid[r["id"]] = cursoid_text
curso_to_cc[cursoid_text] = cc_ids
# 3. PPL ponderado por % invalidación del centro
# El campo % invalidación está en formato decimal (ej. 0.85 = 85%)
# 3. PPL ponderado por % invalidación del centro (decimal: 0.85 = 85%)
ppl_lookup = {}
for cursoid_text, cc_ids in curso_to_cc.items():
total_ppl = sum(
@ -84,30 +84,21 @@ class AirtableClient:
return ppl_lookup, cap_lookup
def get_active_campaigns(self) -> list[dict]:
"""Lee todas las campañas activas desde 'Google Ads Campaigns'."""
records = self.campaigns.all(formula="{Status}='Activa'")
result = []
for r in records:
f = r["fields"]
result.append({
"airtable_id": r["id"],
"curso": f.get("Campaign Name", "Sin nombre"),
"cursoid_text": str(f.get("CursoID Text", "")).strip(),
"google_campaign_id": str(f.get("CampaignID", "")).strip(),
"ppl": float(f.get("PPL", 0)),
"capping_mensual": int(f.get("CapTotalMes", 0)),
"cpa_maximo": float(f.get("CPAMax", 0)),
"margen_objetivo": float(f.get("MargenObjetivo", 0)),
})
return [c for c in result if c["google_campaign_id"]] # descartar sin ID
# ------------------------------------------------------------------ #
# Google Ads Campaigns (catálogo) #
# ------------------------------------------------------------------ #
def sync_campaigns_from_google_ads(self, google_campaigns: list[dict], monthly_metrics: dict = None, ppl_lookup: dict = None, cap_lookup: dict = None) -> dict:
def sync_campaigns_from_google_ads(
self,
google_campaigns: list[dict],
monthly_metrics: dict = None,
ppl_lookup: dict = None,
cap_lookup: dict = None,
) -> dict:
"""
Sincroniza campañas de Google Ads Airtable.
- Crea las campañas nuevas con el status real de Google Ads.
- Actualiza nombre y/o status si han cambiado.
Devuelve un resumen con listas de campañas creadas y actualizadas.
Sincroniza el catálogo de campañas Google Ads Airtable.
Devuelve {created, updated, at_by_gid} donde at_by_gid es el
mapping {google_campaign_id: airtable_record} para reutilizar.
"""
STATUS_MAP = {"ENABLED": "Activa", "PAUSED": "Pausada"}
@ -118,36 +109,24 @@ class AirtableClient:
if r["fields"].get("CampaignID")
}
created = [] # lista de dicts {name, id, status}
updated = [] # lista de dicts {name, id, changes}
created = []
updated = []
to_create = []
to_update = [] # lista de (record_id, changes, name, gid, at_status)
to_update = []
for gc in google_campaigns:
gid = gc["id"]
at_status = STATUS_MAP.get(gc["status"], "Pausada")
at_record = at_by_gid.get(gid)
metrics = (monthly_metrics or {}).get(gid, {})
conv = round(metrics.get("conversions", 0), 2)
cost = round(metrics.get("cost", 0), 2)
# PPL y CapTotalMes calculados desde CursoID Text (viene de Airtable)
# "Google Ads Campaigns" es catálogo: solo nombre, status y PPL
cursoid_text = str((at_record or {}).get("fields", {}).get("CursoID Text", "")).strip() if at_record else ""
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
cpa_max = round(ppl * 0.70, 2)
cap = int((cap_lookup or {}).get(cursoid_text, 0))
if at_record is None:
fields = {"Campaign Name": gc["name"], "CampaignID": gid, "Status": at_status}
if monthly_metrics is not None:
fields["ConvMes"] = conv
fields["CosteMes"] = cost
if ppl_lookup is not None:
fields["PPL"] = ppl
fields["CPAMax"] = cpa_max
if cap_lookup is not None:
fields["CapTotalMes"] = cap
to_create.append(fields)
created.append({"name": gc["name"], "id": gid, "status": at_status})
else:
@ -165,43 +144,232 @@ class AirtableClient:
"antes": at_record["fields"].get("Status"),
"ahora": at_status,
}
if monthly_metrics is not None:
if at_record["fields"].get("ConvMes") != conv:
changes["ConvMes"] = conv
if at_record["fields"].get("CosteMes") != cost:
changes["CosteMes"] = cost
if ppl_lookup is not None:
if at_record["fields"].get("PPL") != ppl:
changes["PPL"] = ppl
if at_record["fields"].get("CPAMax") != cpa_max:
changes["CPAMax"] = cpa_max
if cap_lookup is not None:
if at_record["fields"].get("CapTotalMes") != cap:
changes["CapTotalMes"] = cap
if changes:
to_update.append((at_record["id"], changes, gc["name"]))
if change_detail:
updated.append({"name": gc["name"], "id": gid, "changes": change_detail})
# Crear en lotes de 10
for i in range(0, len(to_create), 10):
self.campaigns.batch_create(to_create[i:i+10])
# Actualizar en lotes de 10
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": changes} for rid, changes, _ in to_update[i:i+10]]
self.campaigns.batch_update(batch)
return {"created": created, "updated": updated}
return {"created": created, "updated": updated, "at_by_gid": at_by_gid}
def get_leads_this_month(self, cursoid_text: str) -> tuple[int, list[str]]:
# ------------------------------------------------------------------ #
# GACampaignMes (estado mensual de campañas con actividad) #
# ------------------------------------------------------------------ #
def sync_gacampaignmes(
self,
google_campaigns: list[dict],
monthly_metrics: dict,
ppl_lookup: dict,
cap_lookup: dict,
at_by_gid: dict,
) -> dict:
"""
Devuelve (count, [record_ids]) de leads del mes actual para un curso.
Filtra por attr_cursoid (que coincide con CursoID Text de la campaña).
Crea/actualiza registros en GACampaignMes para el mes/año en curso.
Se incluyen campañas con actividad durante el mes:
- Estado ENABLED en Google Ads, o
- Coste > 0 en monthly_metrics (activas en algún momento del mes)
"""
STATUS_MAP = {"ENABLED": "Activa", "PAUSED": "Pausada"}
now = datetime.now()
mes_num = str(now.month) # valor numérico "1"…"12"
anio_str = str(now.year)
# GIDs con actividad este mes
active_gids = {
gc["id"]
for gc in google_campaigns
if gc["status"] == "ENABLED"
or (monthly_metrics or {}).get(gc["id"], {}).get("cost", 0) > 0
}
# Registros GACampaignMes ya existentes este mes: {at_campaign_id: gcm_record}
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
existing = self.gacampaignmes.all(formula=formula)
gcm_by_at_cid = {}
for r in existing:
at_cids = r["fields"].get("CampaignID", [])
if at_cids:
gcm_by_at_cid[at_cids[0]] = r
to_create = []
to_update = []
for gc in google_campaigns:
gid = gc["id"]
if gid not in active_gids:
continue
at_record = at_by_gid.get(gid)
if not at_record:
continue
at_cid = at_record["id"]
cursoid_text = str(at_record["fields"].get("CursoID Text", "")).strip()
metrics = (monthly_metrics or {}).get(gid, {})
conv = round(metrics.get("conversions", 0), 2)
cost = round(metrics.get("cost", 0), 2)
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
cpa_max = round(ppl * 0.70, 2)
cap = int((cap_lookup or {}).get(cursoid_text, 0))
at_status = STATUS_MAP.get(gc["status"], "Pausada")
gcm_record = gcm_by_at_cid.get(at_cid)
if gcm_record is None:
to_create.append({
"CampaignID": [at_cid],
"Mes": mes_num,
"Año": anio_str,
"PPL": ppl,
"CPAMax": cpa_max,
"CapTotalMes": cap,
"CosteMes": cost,
"ConvMes": conv,
"Status": at_status,
})
else:
f = gcm_record["fields"]
changes = {}
if f.get("CosteMes") != cost:
changes["CosteMes"] = cost
if f.get("ConvMes") != conv:
changes["ConvMes"] = conv
if f.get("PPL") != ppl:
changes["PPL"] = ppl
if f.get("CPAMax") != cpa_max:
changes["CPAMax"] = cpa_max
if f.get("CapTotalMes") != cap:
changes["CapTotalMes"] = cap
if f.get("Status") != at_status:
changes["Status"] = at_status
if changes:
to_update.append((gcm_record["id"], changes))
for i in range(0, len(to_create), 10):
self.gacampaignmes.batch_create(to_create[i:i+10], typecast=True)
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": f} for rid, f in to_update[i:i+10]]
self.gacampaignmes.batch_update(batch, typecast=True)
return {"created": len(to_create), "updated": len(to_update)}
def get_active_gacampaignmes(self) -> list[dict]:
"""
Lee los registros GACampaignMes del mes/año en curso.
Resuelve el Google Ads campaign ID a partir del registro vinculado
en 'Google Ads Campaigns'.
"""
now = datetime.now()
mes_num = str(now.month)
anio_str = str(now.year)
# Mapping at_record_id → google_campaign_id
campaigns_records = self.campaigns.all(fields=["CampaignID"])
at_id_to_gid = {
r["id"]: str(r["fields"].get("CampaignID", "")).strip()
for r in campaigns_records
if r["fields"].get("CampaignID")
}
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
records = self.gacampaignmes.all(formula=formula)
result = []
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)", [])
name = name_list[0] if name_list else "Sin nombre"
result.append({
"airtable_id": r["id"],
"curso": name,
"cursoid_text": "",
"google_campaign_id": gid,
"ppl": float(f.get("PPL") or 0),
"capping_mensual": int(f.get("CapTotalMes") or 0),
"cpa_maximo": float(f.get("CPAMax") or 0),
"margen_objetivo": float(f.get("MargenObjetivo") or 0),
})
return result
def get_leads_this_month_gads(self, campaign_id: str) -> tuple[int, list[str]]:
"""
Leads del mes actual para una campaña de Google Ads.
Filtra por GoogleCampaignID (extraído de attr_referer),
attr_utm_source pmx/google y attr_utm_medium paid_search.
"""
now = datetime.now()
mes_inicio = f"{now.year}-{now.month:02d}-01"
formula = (
f"AND("
f"{{GoogleCampaignID}}='{campaign_id}',"
f"OR({{attr_utm_source}}='pmx',{{attr_utm_source}}='google'),"
f"{{attr_utm_medium}}='paid_search',"
f"{{creado}}>='{mes_inicio}'"
f")"
)
records = self.leads.all(formula=formula, fields=["airtable id"])
ids = [r["id"] for r in records]
return len(ids), ids
def update_gacampaignmes_leads_lake(self, gcm_record_id: str, lead_ids: list[str]) -> None:
"""Actualiza el campo 'Leads Lake' de un registro GACampaignMes."""
self.gacampaignmes.update(gcm_record_id, {"Leads Lake": lead_ids})
def batch_update_gacampaignmes_consejos(self, updates: list[tuple[str, str]]) -> None:
"""
Actualiza en lote el campo 'Consejo' de varios registros GACampaignMes.
updates: lista de (gcm_record_id, consejo_text)
"""
for i in range(0, len(updates), 10):
batch = [
{"id": rid, "fields": {"Consejo": consejo}}
for rid, consejo in updates[i:i+10]
if consejo
]
if batch:
self.gacampaignmes.batch_update(batch)
# ------------------------------------------------------------------ #
# Métodos legacy (usados internamente / compatibilidad) #
# ------------------------------------------------------------------ #
def get_active_campaigns(self) -> list[dict]:
"""Lee todas las campañas activas desde 'Google Ads Campaigns'."""
records = self.campaigns.all(formula="{Status}='Activa'")
result = []
for r in records:
f = r["fields"]
result.append({
"airtable_id": r["id"],
"curso": f.get("Campaign Name", "Sin nombre"),
"cursoid_text": str(f.get("CursoID Text", "")).strip(),
"google_campaign_id": str(f.get("CampaignID", "")).strip(),
"ppl": float(f.get("PPL", 0)),
"capping_mensual": int(f.get("CapTotalMes", 0)),
"cpa_maximo": float(f.get("CPAMax", 0)),
"margen_objetivo": float(f.get("MargenObjetivo", 0)),
})
return [c for c in result if c["google_campaign_id"]]
def get_leads_this_month(self, cursoid_text: str) -> tuple[int, list[str]]:
"""Leads del mes actual para un curso (por attr_cursoid)."""
now = datetime.now()
mes_inicio = f"{now.year}-{now.month:02d}-01"
formula = (
f"AND("
f"{{attr_cursoid}}='{cursoid_text}',"
@ -213,5 +381,5 @@ class AirtableClient:
return len(ids), ids
def update_campaign_leads_lake(self, campaign_airtable_id: str, lead_ids: list[str]) -> None:
"""Actualiza el campo 'Leads Lake' de una campaña con los IDs de leads del mes."""
"""Actualiza el campo 'Leads Lake' de una campaña en Google Ads Campaigns."""
self.campaigns.update(campaign_airtable_id, {"Leads Lake": lead_ids})

26
run.py
View File

@ -104,10 +104,18 @@ def run():
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_campaigns()
print(f"{len(campaigns)} campañas activas encontradas")
campaigns = at.get_active_gacampaignmes()
print(f"{len(campaigns)} campañas con actividad este mes")
print("→ Analizando...\n")
# === PRIMERA PASADA: recopilar datos de todas las campañas ===
@ -117,8 +125,8 @@ def run():
for campaign in campaigns:
cid = campaign["google_campaign_id"]
leads, lead_ids = at.get_leads_this_month(campaign["cursoid_text"])
at.update_campaign_leads_lake(campaign["airtable_id"], lead_ids)
leads, lead_ids = at.get_leads_this_month_gads(cid)
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
metrics = gads.get_campaign_metrics(cid)
if not metrics:
@ -147,6 +155,7 @@ def run():
# === SEGUNDA PASADA: imprimir en orden + aplicar decisiones ===
resumen = []
consejo_updates = [] # (gcm_record_id, consejo) para batch update final
last_priority = -1
for item in collected:
@ -196,6 +205,9 @@ def run():
apply_decision(campaign, decision, metrics, gads)
if decision.get("consejo"):
consejo_updates.append((campaign["airtable_id"], decision["consejo"]))
resumen.append({
"curso": campaign["curso"],
"urgencia": analysis["urgencia"],
@ -210,6 +222,12 @@ def run():
print()
# Guardar consejos en GACampaignMes
if consejo_updates:
print(f"→ Guardando {len(consejo_updates)} consejos en GACampaignMes...")
at.batch_update_gacampaignmes_consejos(consejo_updates)
print(" ✓ Consejos guardados.")
# Resumen final ordenado por prioridad (ya está ordenado)
print(f"{'='*55}")
print("RESUMEN FINAL")