Recalculate PPL weighted by centro invalidation rate and sync CapTotalMes from CursoMes

- PPL = sum(PPL_centrocurso × % invalidación_centro) for ABIERTO records
  (% invalidación stored as decimal 0-1, not percentage)
- CapTotalMes synced from CursoMes.Caping Admitido for current month/year
  (0 if no record found in CursoMes)
- Table reference fixed: CENTROCURSO → CentroCurso
- Added CursoMes table client in AirtableClient.__init__

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-04-20 16:41:48 +02:00
parent 2191a498d7
commit b0f0ad394b
2 changed files with 67 additions and 20 deletions

View File

@ -9,39 +9,80 @@ class AirtableClient:
self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE) self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE)
self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.CAMPAIGNS_TABLE) self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.CAMPAIGNS_TABLE)
self.cursos = self.api.table(config.AIRTABLE_BASE_ID, "Cursos") self.cursos = self.api.table(config.AIRTABLE_BASE_ID, "Cursos")
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CENTROCURSO") self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
def build_ppl_lookup(self) -> dict: MESES_ES = {
""" 1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
Construye un dict {cursoid_text: ppl_sum} calculando el PPL de cada curso 5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
sumando los PPL de los registros CENTROCURSO con Estado ROI = 'ABIERTO'. 9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre",
}
Cadena: CursoID Text Cursos.CursoID Cursos.CentroCurso CENTROCURSO.PPL def build_campaign_lookups(self) -> tuple[dict, dict]:
Solo se usan en lotes (2 llamadas bulk), sin llamadas por campaña.
""" """
# 1. Cargar CENTROCURSO ABIERTO: {record_id: ppl} Construye en 3 llamadas bulk:
ppl_lookup: {cursoid_text: ppl_ponderado}
PPL = Σ (PPL_centrocurso × % invalidación_centro / 100)
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
"""
now = datetime.now()
mes_nombre = self.MESES_ES[now.month]
anio_str = str(now.year)
# 1. CentroCurso ABIERTO: {record_id: {ppl, pct_invalidacion}}
cc_records = self.centrocurso.all( cc_records = self.centrocurso.all(
formula="{Estado ROI}='ABIERTO'", formula="{Estado ROI}='ABIERTO'",
fields=["PPL"], fields=["PPL", "% invalidación (from Centros)"],
) )
cc_ppl = {r["id"]: float(r["fields"].get("PPL") or 0) for r in cc_records} cc_data = {}
for r in cc_records:
ppl_val = float(r["fields"].get("PPL") or 0)
pct_raw = r["fields"].get("% invalidación (from Centros)", 0)
if isinstance(pct_raw, list):
pct = float(pct_raw[0]) if pct_raw else 0.0
else:
pct = float(pct_raw or 0)
cc_data[r["id"]] = {"ppl": ppl_val, "pct": pct}
# 2. Cargar Cursos: {cursoid_number: [cc_record_ids]} # 2. Cursos: {record_id: cursoid_text} + {cursoid_text: [cc_ids]}
cursos_records = self.cursos.all(fields=["CursoID", "CentroCurso"]) cursos_records = self.cursos.all(fields=["CursoID", "CentroCurso"])
curso_by_recordid = {}
curso_to_cc = {} curso_to_cc = {}
for r in cursos_records: for r in cursos_records:
cid = r["fields"].get("CursoID") cid = r["fields"].get("CursoID")
cc_ids = r["fields"].get("CentroCurso", []) cc_ids = r["fields"].get("CentroCurso", [])
if cid is not None: if cid is not None:
curso_to_cc[str(int(cid))] = cc_ids cursoid_text = str(int(cid))
curso_by_recordid[r["id"]] = cursoid_text
curso_to_cc[cursoid_text] = cc_ids
# 3. Para cada CursoID Text, sumar PPL de CENTROCURSO ABIERTO # 3. PPL ponderado por % invalidación del centro
# El campo % invalidación está en formato decimal (ej. 0.85 = 85%)
ppl_lookup = {} ppl_lookup = {}
for cursoid_text, cc_ids in curso_to_cc.items(): for cursoid_text, cc_ids in curso_to_cc.items():
total_ppl = sum(cc_ppl[cc_id] for cc_id in cc_ids if cc_id in cc_ppl) total_ppl = sum(
cc_data[cc_id]["ppl"] * cc_data[cc_id]["pct"]
for cc_id in cc_ids if cc_id in cc_data
)
ppl_lookup[cursoid_text] = round(total_ppl, 2) ppl_lookup[cursoid_text] = round(total_ppl, 2)
return ppl_lookup # 4. CursoMes del mes/año actual → cap_lookup
cap_formula = f"AND({{Mes}}='{mes_nombre}',{{Año}}='{anio_str}')"
cursomes_records = self.cursomes.all(
formula=cap_formula,
fields=["CursoID", "Caping Admitido"],
)
cap_lookup = {}
for r in cursomes_records:
cap = int(r["fields"].get("Caping Admitido") or 0)
for curso_rec_id in r["fields"].get("CursoID", []):
cursoid_text = curso_by_recordid.get(curso_rec_id)
if cursoid_text:
cap_lookup[cursoid_text] = cap
return ppl_lookup, cap_lookup
def get_active_campaigns(self) -> list[dict]: def get_active_campaigns(self) -> list[dict]:
"""Lee todas las campañas activas desde 'Google Ads Campaigns'.""" """Lee todas las campañas activas desde 'Google Ads Campaigns'."""
@ -61,7 +102,7 @@ class AirtableClient:
}) })
return [c for c in result if c["google_campaign_id"]] # descartar sin ID return [c for c in result if c["google_campaign_id"]] # descartar sin ID
def sync_campaigns_from_google_ads(self, google_campaigns: list[dict], monthly_metrics: dict = None, ppl_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. Sincroniza campañas de Google Ads Airtable.
- Crea las campañas nuevas con el status real de Google Ads. - Crea las campañas nuevas con el status real de Google Ads.
@ -91,10 +132,11 @@ class AirtableClient:
conv = round(metrics.get("conversions", 0), 2) conv = round(metrics.get("conversions", 0), 2)
cost = round(metrics.get("cost", 0), 2) cost = round(metrics.get("cost", 0), 2)
# PPL calculado desde CENTROCURSO (CursoID Text viene de Airtable) # PPL y CapTotalMes calculados desde CursoID Text (viene de Airtable)
cursoid_text = str((at_record or {}).get("fields", {}).get("CursoID Text", "")).strip() if at_record else "" 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) ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
cpa_max = round(ppl * 0.70, 2) cpa_max = round(ppl * 0.70, 2)
cap = int((cap_lookup or {}).get(cursoid_text, 0))
if at_record is None: if at_record is None:
fields = {"Campaign Name": gc["name"], "CampaignID": gid, "Status": at_status} fields = {"Campaign Name": gc["name"], "CampaignID": gid, "Status": at_status}
@ -104,6 +146,8 @@ class AirtableClient:
if ppl_lookup is not None: if ppl_lookup is not None:
fields["PPL"] = ppl fields["PPL"] = ppl
fields["CPAMax"] = cpa_max fields["CPAMax"] = cpa_max
if cap_lookup is not None:
fields["CapTotalMes"] = cap
to_create.append(fields) to_create.append(fields)
created.append({"name": gc["name"], "id": gid, "status": at_status}) created.append({"name": gc["name"], "id": gid, "status": at_status})
else: else:
@ -131,6 +175,9 @@ class AirtableClient:
changes["PPL"] = ppl changes["PPL"] = ppl
if at_record["fields"].get("CPAMax") != cpa_max: if at_record["fields"].get("CPAMax") != cpa_max:
changes["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: if changes:
to_update.append((at_record["id"], changes, gc["name"])) to_update.append((at_record["id"], changes, gc["name"]))
if change_detail: if change_detail:

6
run.py
View File

@ -54,9 +54,9 @@ def run():
print("→ Sincronizando campañas desde Google Ads...") print("→ Sincronizando campañas desde Google Ads...")
google_campaigns = gads.get_all_campaigns() google_campaigns = gads.get_all_campaigns()
monthly_metrics = gads.get_monthly_metrics_all() monthly_metrics = gads.get_monthly_metrics_all()
print(" Calculando PPL desde CENTROCURSO...") print(" Calculando PPL y CapTotalMes...")
ppl_lookup = at.build_ppl_lookup() ppl_lookup, cap_lookup = at.build_campaign_lookups()
sync_result = at.sync_campaigns_from_google_ads(google_campaigns, monthly_metrics, ppl_lookup) sync_result = at.sync_campaigns_from_google_ads(google_campaigns, monthly_metrics, ppl_lookup, cap_lookup)
if sync_result["created"]: if sync_result["created"]:
print(f" ✅ Campañas nuevas importadas ({len(sync_result['created'])}):") print(f" ✅ Campañas nuevas importadas ({len(sync_result['created'])}):")
for c in sync_result["created"]: for c in sync_result["created"]: