From b0f0ad394b090b1b5dc711dd80deed4f28664cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Mon, 20 Apr 2026 16:41:48 +0200 Subject: [PATCH] Recalculate PPL weighted by centro invalidation rate and sync CapTotalMes from CursoMes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- airtable_client.py | 81 ++++++++++++++++++++++++++++++++++++---------- run.py | 6 ++-- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/airtable_client.py b/airtable_client.py index 571bdf3..cdbad4b 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -9,39 +9,80 @@ class AirtableClient: 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.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: - """ - Construye un dict {cursoid_text: ppl_sum} calculando el PPL de cada curso - sumando los PPL de los registros CENTROCURSO con Estado ROI = 'ABIERTO'. + MESES_ES = { + 1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril", + 5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto", + 9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre", + } - Cadena: CursoID Text → Cursos.CursoID → Cursos.CentroCurso → CENTROCURSO.PPL - Solo se usan en lotes (2 llamadas bulk), sin llamadas por campaña. + def build_campaign_lookups(self) -> tuple[dict, dict]: """ - # 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( 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"]) + curso_by_recordid = {} curso_to_cc = {} for r in cursos_records: cid = r["fields"].get("CursoID") cc_ids = r["fields"].get("CentroCurso", []) 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 = {} 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) - 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]: """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 - 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. - 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) 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 "" 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} @@ -104,6 +146,8 @@ class AirtableClient: 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: @@ -131,6 +175,9 @@ class AirtableClient: 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: diff --git a/run.py b/run.py index 4cecafd..789b0fc 100644 --- a/run.py +++ b/run.py @@ -54,9 +54,9 @@ def run(): print("→ Sincronizando campañas desde Google Ads...") google_campaigns = gads.get_all_campaigns() monthly_metrics = gads.get_monthly_metrics_all() - print(" Calculando PPL desde CENTROCURSO...") - ppl_lookup = at.build_ppl_lookup() - sync_result = at.sync_campaigns_from_google_ads(google_campaigns, monthly_metrics, ppl_lookup) + 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"]: