# backend/data_manager.py (v2 - reescrito completo) """Capa de acceso a datos del módulo LEADS.""" import os import time import requests from dotenv import load_dotenv load_dotenv() # ── Mapa de campañas (frase -> programa/sede/rango fechas), igual que el PBI ── CAMPANIAS = [ ('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'), ('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'), ('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), ('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), ('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), ('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'), ('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), ('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'), ('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'), ('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'), ('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'), ('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'), ('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'), ('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'), ('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'), ('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'), ('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), (' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), (' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), (' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), (' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), (' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), ('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), ('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'), ('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), ('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), ('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), ('📱 Estoy interesado Instalación', 'VRF', '81a', 'Lima', '-', 'Pauta_wsp', '2026-06-25', '2026-08-30'), ('🧊 Más información del Seminario', 'VRF', '82a', 'Arequipa', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), ('📐 Más información del Seminario', 'VRF', '83a', 'Trujillo', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), ('👾 Más información del Seminario', 'VRF', '84a', 'Piura', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), ('🛠️ más info del Seminario Virtual ', 'Diseño de Chillers', '85a', 'Lima', '-', 'Pauta_wsp', '2026-07-01', '2026-09-30'), ('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'), ('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'), ('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'), ('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'), ('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), ('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'), ('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'), ('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'), ('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'), ('¡Hola! ⚡', 'TEAC', '230', 'Trujillo', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'), ('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'), ('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'), ('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'), ('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'), ('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'), ('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'), ('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'), ('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'), ('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'), ('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), ('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), ('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'), ('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), ('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), ('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), ('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), ('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'), ('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'), ('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'), ('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'), ('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'), ('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'), ('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'), ('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'), ('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'), ('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'), ('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'), ('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), ('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), ('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), ('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), ('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31'), ] # Cache en memoria de las campañas leídas de Supabase (expira cada 15 min, # igual que el dashboard de ventas). Así las campañas nuevas aparecen solas # sin reiniciar el servidor. _CAMPANIAS_CACHE = None _CAMPANIAS_TS = 0.0 _CAMPANIAS_TTL = 900 # 15 min def _campanias_supabase(): """Lee las campañas desde la tabla 'campanias' de Supabase (misma fuente que la cartera). Devuelve lista de tuplas (frase,cargo,codigo,sede,dia,origen,fi,ff). Si falla o está vacía, cae a la lista hardcodeada CAMPANIAS como respaldo. El cache expira cada 15 min para tomar campañas nuevas sin reiniciar.""" global _CAMPANIAS_CACHE, _CAMPANIAS_TS if _CAMPANIAS_CACHE is not None and (time.time() - _CAMPANIAS_TS < _CAMPANIAS_TTL): return _CAMPANIAS_CACHE url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") out = [] if url and key: try: r = requests.get( f"{url}/rest/v1/{t}", params={"select": "frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): frase = str(row.get("frase_busqueda") or "") if not frase: continue out.append(( frase, str(row.get("cargo") or ""), str(row.get("codigo") or ""), str(row.get("sede") or ""), str(row.get("dia") or ""), str(row.get("origen") or ""), str(row.get("fecha_inicio") or "")[:10], str(row.get("fecha_fin") or "")[:10], )) except Exception as e: print(f"[_campanias_supabase] {e}") if not out: print("[_campanias_supabase] sin datos de Supabase -> uso lista hardcodeada") out = list(CAMPANIAS) _CAMPANIAS_CACHE = out _CAMPANIAS_TS = time.time() return out def invalidar_campanias_cache(): """Vacía el cache de campañas para forzar re-lectura de Supabase en la próxima llamada. Se invoca desde refrescar_todo() cada 15 min, para que las campañas se refresquen junto con el resto del dashboard.""" global _CAMPANIAS_CACHE, _CAMPANIAS_TS _CAMPANIAS_CACHE = None _CAMPANIAS_TS = 0.0 def _values_sql(): """Construye el bloque VALUES (...) del mapa de campañas para el JOIN. Lee las campañas desde la tabla 'campanias' de Supabase (ya no hardcodeadas).""" filas = [] for frase, cargo, codigo, sede, dia, origen, fi, ff in _campanias_supabase(): f = frase.replace("'", "''") filas.append(f"('{f}','{cargo}','{codigo}','{sede}','{dia}','{origen}','{fi}','{ff}')") return ",\n".join(filas) class DataManager: def __init__(self): self.pg_host = os.getenv("PG_HOST", "191.98.134.81") self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production") self.pg_user = os.getenv("PG_USER", "postgres") self.pg_pass = os.getenv("PG_PASSWORD", "") self.pg_port = os.getenv("PG_PORT", "5432") self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80") self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001") self.sql_user = os.getenv("SQL_USERNAME", "") self.sql_pass = os.getenv("SQL_PASSWORD", "") def pg_conn(self): import psycopg2 return psycopg2.connect( host=self.pg_host, dbname=self.pg_db, user=self.pg_user, password=self.pg_pass, port=self.pg_port, ) def sql_conn(self): import pyodbc conn_str = ( f"DRIVER={{SQL Server}};SERVER={self.sql_server};" f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}" ) return pyodbc.connect(conn_str) # ── CHATWOOT: leads de pauta (mapa de campañas, dedup por mensaje) ── def traer_leads_chatwoot(self): sql = f""" SELECT DISTINCT ON (m.id) -- Normalización IGUAL que el PBI: quitar '+51' y '+', quedando el número REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, u.name AS asesor, m.created_at AS fecha_creada, cv.cached_label_list AS etiquetas, map.cargo AS programa, map.sede AS sede, map.codigo AS codigo, map.origen AS origen FROM messages m JOIN conversations cv ON m.conversation_id = cv.id JOIN contacts c ON cv.contact_id = c.id LEFT JOIN users u ON cv.assignee_id = u.id JOIN (VALUES {_values_sql()} ) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin) ON m.content LIKE '%' || map.frase_busqueda || '%' AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP) AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day' WHERE m.sender_type = 'Contact' ORDER BY m.id ASC """ conn = self.pg_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── SQL SERVER: cursos ── def traer_cursos(self): sql = """ SELECT rp.num_indice, rp.dsc_det_programa, p.dsc_programa, rp.cod_frecuencia, rp.fch_inicio, rp.cod_estado, (SELECT COUNT(*) FROM sgeca_matricula m WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales, (SELECT COUNT(*) FROM sgeca_matricula m WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice AND m.cod_estado = 'RET') AS Inscritos_Retirados, (SELECT COUNT(*) FROM sgeca_matricula m WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos FROM sgede_RP_programa rp INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa WHERE YEAR(rp.fch_inicio) IN (2025, 2026) ORDER BY rp.fch_inicio ASC """ conn = self.sql_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── SUPABASE: mapa num_indice → pauta (tabla basebi_programacion) ── def traer_pauta_cursos(self): """Devuelve dict {str(num_indice): {pauta, estado, contar}} desde Supabase. Fuente: tabla BaseBI_Programacion de SharePoint, subida a Supabase.""" url = os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("SUPABASE_PAUTA_KEY", "") tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") if not url or not key: return {} try: r = requests.get( f"{url}/rest/v1/{tabla}", params={"select": "num_indice,pauta_codigo,estado,contar"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30, ) r.raise_for_status() mapa = {} for row in r.json(): ni = row.get("num_indice") if ni is None: continue pa = row.get("pauta_codigo") mapa[str(ni)] = { "pauta": (str(pa).strip() if pa not in (None, "") else None), "estado": (str(row.get("estado") or "").strip().upper()), "contar": (str(row.get("contar") or "").strip().upper()), } return mapa except Exception as e: print(f"[traer_pauta_cursos] {e}") return {} # ── SUPABASE: valores de programa/sede no identificados (para la campanita) ── def traer_no_identificados(self): """Compara cartera_junta vs alias_normalizacion y devuelve los valores de programa/sede que NO están en el diccionario (correcto). Retorna {'programa': [{'valor','veces'}], 'sede': [...]}.""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t_cart = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta") t_alias = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") vacio = {"programa": [], "sede": []} if not url or not key: return vacio headers = {"apikey": key, "Authorization": f"Bearer {key}"} try: # 1) valores conocidos (correcto) por tipo r = requests.get(f"{url}/rest/v1/{t_alias}", params={"select": "tipo,correcto"}, headers=headers, timeout=30) r.raise_for_status() conocidos = {"PROGRAMA": set(), "SEDE": set()} for row in r.json(): tipo = str(row.get("tipo") or "").upper() corr = " ".join(str(row.get("correcto") or "").upper().split()) if tipo in conocidos: conocidos[tipo].add(corr) # 2) traer de cartera_junta los valores que NO estén en los conocidos def _desconocidos(columna, tipo): conj = conocidos.get(tipo, set()) if not conj: return [] lista = ",".join('"' + v.replace('"', '') + '"' for v in conj) rr = requests.get( f"{url}/rest/v1/{t_cart}", params={"select": columna, columna: f"not.in.({lista})", "limit": "5000"}, headers=headers, timeout=30) rr.raise_for_status() cont = {} for row in rr.json(): v = " ".join(str(row.get(columna) or "").upper().split()) cont[v] = cont.get(v, 0) + 1 return [{"valor": k, "veces": n} for k, n in sorted(cont.items(), key=lambda x: -x[1])] return {"programa": _desconocidos("programa", "PROGRAMA"), "sede": _desconocidos("sede", "SEDE")} except Exception as e: print(f"[traer_no_identificados] {e}") return vacio # ── SUPABASE: diccionario alias (para normalizar campanias como la cartera) ── def traer_alias_map(self): """{'PROGRAMA': {alias:correcto}, 'SEDE': {alias:correcto}}.""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") out = {"PROGRAMA": {}, "SEDE": {}} if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "tipo,alias,correcto"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): tipo = str(row.get("tipo") or "").upper() al = " ".join(str(row.get("alias") or "").upper().split()) co = str(row.get("correcto") or "") if tipo in out: out[tipo][al] = co except Exception as e: print(f"[traer_alias_map] {e}") return out # ── SUPABASE: asesores -> {alias_norm: correcto} y lista de correctos (los vendedores) ── def traer_asesores(self): """Devuelve {'map': {alias_norm: correcto}, 'lista': [correctos ordenados]} de tipo ASESOR (solo con correcto no vacío).""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") mapa = {}; correctos = set() if not url or not key: return {"map": mapa, "lista": []} try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "alias,correcto", "tipo": "eq.ASESOR"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): al = " ".join(str(row.get("alias") or "").upper().split()) co = str(row.get("correcto") or "").strip() if co: mapa[al] = co correctos.add(co) # el propio correcto tambien es su alias mapa[" ".join(co.upper().split())] = co except Exception as e: print(f"[traer_asesores] {e}") return {"map": mapa, "lista": sorted(correctos)} # ── SUPABASE: mapa codigo → (sede, programa) desde campanias (normalizado) ── def traer_campanias_map(self): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") out = {} if not url or not key: return out alias = self.traer_alias_map() def _norm(v, tipo): s = " ".join(str(v or "").upper().split()) return alias.get(tipo, {}).get(s, s) try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): cod = " ".join(str(row.get("codigo") or "").upper().split()) if not cod: continue out[cod] = (_norm(row.get("sede"), "SEDE"), _norm(row.get("cargo"), "PROGRAMA")) except Exception as e: print(f"[traer_campanias_map] {e}") return out # ── SUPABASE: filas de cartera_junta para contar (telefono, sede, programa, fecha) ── def traer_cartera_rows(self): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta") out = [] if not url or not key: return out paso, desde = 1000, 0 sel = "telefono,sede,programa,fecha_creada,asesor,es_origen,canal,origen_base" try: while True: hdr = {"apikey": key, "Authorization": f"Bearer {key}", "Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"} r = requests.get(f"{url}/rest/v1/{t}", params={"select": sel}, headers=hdr, timeout=60) r.raise_for_status() data = r.json() if not data: break out.extend(data) desde += len(data) # avanza por lo realmente recibido (Supabase puede topar el bloque) if len(data) < paso: break except Exception as e: print(f"[traer_cartera_rows] {e}") return out # ── SUPABASE: mapa pauta → [conjuntos de anuncios] (tabla conjunto_pauta) ── def traer_conjunto_pauta(self): """Devuelve dict {pauta(str): [conjunto, ...]} desde la tabla conjunto_pauta. Una pauta puede tener varios conjuntos de anuncios.""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") out = {} if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "conjunto,pauta"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): pa = str(row.get("pauta") or "").strip() co = " ".join(str(row.get("conjunto") or "").split()) if not pa or not co: continue out.setdefault(pa, []).append(co) except Exception as e: print(f"[traer_conjunto_pauta] {e}") return out # ── SUPABASE: asignacion manual conjunto -> sede + programa (tabla conjunto_sede_programa) ── def traer_conjunto_sede_programa(self): """Devuelve {conjunto_UPPER: {"sede":..., "programa":...}} desde la tabla.""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = "conjunto_sede_programa" out = {} if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "conjunto,sede,programa"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): co = " ".join(str(row.get("conjunto") or "").split()) if not co: continue out[co.upper()] = { "sede": " ".join(str(row.get("sede") or "").upper().split()), "programa": " ".join(str(row.get("programa") or "").upper().split()), } except Exception as e: print(f"[traer_conjunto_sede_programa] {e}") return out def guardar_conjunto_sede_programa(self, cambios): """Upsert de asignaciones. cambios = [{conjunto, sede, programa}, ...].""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = "conjunto_sede_programa" if not url or not key or not cambios: return {"guardados": 0} headers = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates,return=minimal"} payload = [] for c in cambios: co = " ".join(str(c.get("conjunto") or "").split()) if not co: continue payload.append({ "conjunto": co, "sede": " ".join(str(c.get("sede") or "").upper().split()) or None, "programa": " ".join(str(c.get("programa") or "").upper().split()) or None, }) if not payload: return {"guardados": 0} r = requests.post(f"{url}/rest/v1/{t}?on_conflict=conjunto", json=payload, headers=headers, timeout=30) r.raise_for_status() return {"guardados": len(payload)} def borrar_conjunto_sede_programa(self, conjunto): """Borra la asignacion de un conjunto (lo deja sin sede/programa).""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = "conjunto_sede_programa" co = " ".join(str(conjunto or "").split()) if not url or not key or not co: return {"borrado": 0} headers = {"apikey": key, "Authorization": f"Bearer {key}"} r = requests.delete(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"}, headers=headers, timeout=30) r.raise_for_status() return {"borrado": 1} # ── GOOGLE SHEET (CSV publicado): importe gastado por conjunto de anuncios ── def traer_meta_importe(self): """Lee el CSV publicado de Meta_Ads_Adsets y devuelve una lista de dicts: [{conjunto, importe, inicio}, ...] (una por fila del CSV, inicio = 'Inicio del informe'). Se conserva la fecha para poder filtrar por periodo (Importe Pauta en el Mes).""" import csv, io url = os.getenv("META_CSV_URL", "") out = [] if not url: return out try: r = requests.get(url, timeout=30) r.raise_for_status() r.encoding = "utf-8" rows = list(csv.reader(io.StringIO(r.text))) if not rows: return out enc = [" ".join(str(h or "").split()).lower() for h in rows[0]] def _idx(*claves): for k in claves: for i, h in enumerate(enc): if k in h: return i return -1 # OJO: buscar "nombre del conjunto..." primero (hay tambien "ID del conjunto..."). i_conj = _idx("nombre del conjunto de anuncios", "nombre del conjunto") i_imp = _idx("importe gastado") i_ini = _idx("inicio del informe") i_res = _idx("resultados") if i_conj < 0 or i_imp < 0: print(f"[traer_meta_importe] columnas no encontradas: {enc}") return out for fila in rows[1:]: if len(fila) <= max(i_conj, i_imp): continue co = " ".join(str(fila[i_conj] or "").split()) if not co: continue raw = str(fila[i_imp] or "").strip().replace(",", "") try: val = float(raw) if raw else 0.0 except ValueError: val = 0.0 rraw = str(fila[i_res] or "").strip().replace(",", "") if i_res >= 0 and len(fila) > i_res else "" try: res = float(rraw) if rraw else 0.0 except ValueError: res = 0.0 ini = str(fila[i_ini] or "").strip() if i_ini >= 0 and len(fila) > i_ini else "" out.append({"conjunto": co, "importe": val, "resultados": res, "inicio": ini}) except Exception as e: print(f"[traer_meta_importe] {e}") return out # ── CHATWOOT: leads asignados (mensaje "Asignado a..."/auto-asignado, ultimo por telefono) ── def traer_leads_asignados(self): """Devuelve [{telefono, fecha_asignada(date, -5h), user_name}] tomando el mensaje de asignacion mas reciente por telefono.""" sql = """ SELECT * FROM ( SELECT DISTINCT ON (c.phone_number) REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, m.id AS message_id, (m.created_at - INTERVAL '5 hours') AS created_at, COALESCE(u.name, 'Sin Asesor') AS user_name, cv.cached_label_list AS etiquetas FROM messages m JOIN conversations cv ON m.conversation_id = cv.id JOIN contacts c ON cv.contact_id = c.id LEFT JOIN users u ON cv.assignee_id = u.id WHERE ( m.content LIKE 'Asignado a %' OR m.content LIKE '% auto-asignado%' ) ORDER BY c.phone_number, m.id DESC ) AS t ORDER BY message_id DESC """ conn = self.pg_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── CHATWOOT: ultima asignacion por telefono + primer mensaje del asesor (respuesta) ── def traer_asignacion_respuesta(self): """Por telefono: la ULTIMA asignacion (T1) y el primer mensaje posterior de un ASESOR real (sender_id 15-25) como respuesta (T2). Fechas en hora Peru (-5h). Devuelve [{telefono, user_id, user_name, created_at(T1), respuesta_fecha(T2)}]. Si respuesta_fecha es None -> no respondio.""" sql = """ SELECT * FROM ( SELECT DISTINCT ON (c.phone_number) REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, m.id AS message_id, (m.created_at - INTERVAL '5 hours') AS created_at, u.id AS user_id, COALESCE(u.name, 'Sin Asesor') AS user_name, cv.cached_label_list AS etiquetas, (respuesta.created_at - INTERVAL '5 hours') AS respuesta_fecha FROM messages m JOIN conversations cv ON m.conversation_id = cv.id JOIN contacts c ON cv.contact_id = c.id LEFT JOIN users u ON cv.assignee_id = u.id LEFT JOIN LATERAL ( SELECT m2.created_at FROM messages m2 WHERE m2.conversation_id = m.conversation_id AND m2.id > m.id AND m2.sender_id BETWEEN 15 AND 25 ORDER BY m2.id ASC LIMIT 1 ) AS respuesta ON TRUE WHERE ( m.content LIKE 'Asignado a %' OR m.content LIKE '% auto-asignado%' ) ORDER BY c.phone_number, m.id DESC ) AS t ORDER BY message_id DESC """ conn = self.pg_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── CHATWOOT: actividad de asesores (para detectar turno del dia). ── def traer_actividad_asesores(self): """Mensajes ENVIADOS por asesores reales (sender_id 15-25), en hora Peru (-5h). Sirve para detectar el turno de cada asesor cada dia por concentracion de actividad. Devuelve [{user_id, created_at}]. Liviano: solo id + fecha.""" sql = """ SELECT m.sender_id AS user_id, (m.created_at - INTERVAL '5 hours') AS created_at FROM messages m WHERE m.sender_id BETWEEN 15 AND 25 """ conn = self.pg_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── SUPABASE: actualizar/insertar la pauta de un conjunto en conjunto_pauta ── def upsert_conjunto_pauta(self, conjunto, pauta): """Si el conjunto ya existe en conjunto_pauta, actualiza su pauta; si no, lo crea. Si pauta viene vacia, elimina el vinculo (deja el conjunto sin pauta).""" url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} co = str(conjunto).strip(); pa = str(pauta or "").strip() # ¿existe el conjunto? r = requests.get(f"{url}/rest/v1/{t}", params={"select": "conjunto", "conjunto": f"eq.{co}"}, headers=h, timeout=30) r.raise_for_status() existe = len(r.json()) > 0 if not pa: # sin pauta -> borrar vinculo si existia if existe: requests.delete(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"}, headers=h, timeout=30).raise_for_status() return {"conjunto": co, "pauta": None} if existe: requests.patch(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"}, headers=h, json={"pauta": pa}, timeout=30).raise_for_status() else: requests.post(f"{url}/rest/v1/{t}", headers=h, json={"conjunto": co, "pauta": pa}, timeout=30).raise_for_status() return {"conjunto": co, "pauta": pa} # ── SUPABASE: conjuntos de anuncios SIN pauta conectada (del Sheet, no en conjunto_pauta) ── def traer_conjuntos_sin_pauta(self): """Lista de conjuntos del Google Sheet que NO estan en conjunto_pauta.""" conj_pauta = self.traer_conjunto_pauta() # {pauta: [conjuntos]} conectados = set() for cjs in conj_pauta.values(): for c in cjs: conectados.add(" ".join(str(c).split()).upper()) vistos = {} for f in self.traer_meta_importe(): nombre = " ".join(str(f.get("conjunto")).split()) if not nombre: continue if " ".join(nombre.split()).upper() not in conectados: vistos[nombre] = True return sorted(vistos.keys()) # ── SUPABASE: programa_pautas -> {num_indice: [pautas]} (muchos-a-muchos) ── def traer_programa_pautas(self): url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas") out = {} if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "num_indice,pauta"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): ni = str(row.get("num_indice") or "").strip() pa = str(row.get("pauta") or "").strip() if ni and pa: out.setdefault(ni, []).append(pa) except Exception as e: print(f"[traer_programa_pautas] {e}") return out # ── SUPABASE: agregar un vinculo num_indice<->pauta en programa_pautas (upsert) ── def agregar_programa_pauta(self, num_indice, pauta): url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} ni = str(num_indice).strip(); pa = str(pauta).strip() # ¿ya existe ese par? r = requests.get(f"{url}/rest/v1/{t}", params={"select": "id", "num_indice": f"eq.{ni}", "pauta": f"eq.{pa}"}, headers=h, timeout=30) r.raise_for_status() if len(r.json()) > 0: return {"num_indice": ni, "pauta": pa, "ya_existia": True} rr = requests.post(f"{url}/rest/v1/{t}", headers=h, json={"num_indice": ni, "pauta": pa}, timeout=30) rr.raise_for_status() return {"num_indice": ni, "pauta": pa, "ya_existia": False} # ── SUPABASE: num_indices que YA usan una pauta en basebi_programacion ── def num_indices_de_pauta(self, pauta): url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") try: r = requests.get(f"{url}/rest/v1/{tabla}", params={"select": "num_indice", "pauta_codigo": f"eq.{str(pauta).strip()}"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() return [str(x.get("num_indice")).strip() for x in r.json() if x.get("num_indice") is not None] except Exception as e: print(f"[num_indices_de_pauta] {e}") return [] # ── SUPABASE: guardar pauta y/o contar de un num_indice en basebi_programacion (upsert) ── def guardar_pauta_basebi(self, num_indice, pauta, contar=None): url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} ni = str(num_indice).strip(); pa = str(pauta or "").strip() campos = {} if pa: campos["pauta_codigo"] = pa # solo actualizar pauta si viene con valor if contar is not None: campos["contar"] = str(contar).strip().upper() # "SI" / "NO" if not campos: return {"num_indice": ni, "sin_cambios": True} r = requests.get(f"{url}/rest/v1/{tabla}", params={"select": "num_indice", "num_indice": f"eq.{ni}"}, headers=h, timeout=30) r.raise_for_status() existe = len(r.json()) > 0 if existe: rr = requests.patch(f"{url}/rest/v1/{tabla}", params={"num_indice": f"eq.{ni}"}, headers=h, json=campos, timeout=30) else: rr = requests.post(f"{url}/rest/v1/{tabla}", headers=h, json={"num_indice": ni, **campos}, timeout=30) rr.raise_for_status() return {"num_indice": ni, "pauta": pa, "contar": campos.get("contar"), "creado": not existe} # ── SUPABASE: vincular un conjunto de anuncios a una pauta (insert en conjunto_pauta) ── def guardar_conjunto_pauta(self, conjunto, pauta): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} rr = requests.post(f"{url}/rest/v1/{t}", headers=h, json={"conjunto": str(conjunto).strip(), "pauta": str(pauta).strip()}, timeout=30) rr.raise_for_status() return {"conjunto": conjunto, "pauta": pauta} # ── CHATWOOT: plantillas enviadas (para matriz de plantillas cobradas) ── def traer_plantillas(self): """Devuelve [{plantilla, telefono, user_name, created_at_peru, hora_anterior_contacto, siguiente_mensaje_1}] de mensajes de plantilla con content_attributes vacio (ISBLANK).""" sql = """ SELECT m.additional_attributes -> 'template_params' ->> 'name' AS plantilla, REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, u.name AS user_name, (m.created_at - INTERVAL '5 hours') AS created_at_peru, (SELECT (mp.created_at - INTERVAL '5 hours') FROM messages mp WHERE mp.conversation_id = m.conversation_id AND mp.id < m.id AND mp.sender_type = 'Contact' ORDER BY mp.id DESC LIMIT 1) AS hora_anterior_contacto, (SELECT m2.content FROM messages m2 WHERE m2.conversation_id = m.conversation_id AND m2.id > m.id AND m2.sender_type = 'Contact' ORDER BY m2.id ASC LIMIT 1) AS siguiente_mensaje_1 FROM messages m JOIN conversations cv ON m.conversation_id = cv.id JOIN contacts c ON cv.contact_id = c.id LEFT JOIN users u ON cv.assignee_id = u.id WHERE m.sender_type = 'User' AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL AND (m.content_attributes IS NULL OR m.content_attributes::text = '{}' OR m.content_attributes::text = 'null') """ conn = self.pg_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() return filas # ── SUPABASE: campanias -> {codigo(pauta): {sede, cargo}} (crudo, sin normalizar) ── def traer_campanias_sede_cargo(self): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") out = {} if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): cod = str(row.get("codigo") or "").strip() if cod: out[cod] = {"sede": str(row.get("sede") or "").strip(), "cargo": str(row.get("cargo") or "").strip()} except Exception as e: print(f"[traer_campanias_sede_cargo] {e}") return out # ── SUPABASE campanias: LISTAR todas (con id) para el apartado Leyenda ── def listar_campanias(self): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") out = [] if not url or not key: return out try: r = requests.get(f"{url}/rest/v1/{t}", params={"select": "id,frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin", "order": "codigo"}, headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) r.raise_for_status() for row in r.json(): out.append({ "id": row.get("id"), "frase_busqueda": str(row.get("frase_busqueda") or ""), "cargo": str(row.get("cargo") or ""), "codigo": str(row.get("codigo") or ""), "sede": str(row.get("sede") or ""), "dia": str(row.get("dia") or ""), "origen": str(row.get("origen") or ""), "fecha_inicio": str(row.get("fecha_inicio") or "")[:10], "fecha_fin": str(row.get("fecha_fin") or "")[:10], }) except Exception as e: print(f"[listar_campanias] {e}") return out # ── SUPABASE campanias: AGREGAR una fila ── def agregar_campania(self, datos): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Prefer": "return=representation"} fila = { "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), "cargo": str(datos.get("cargo") or "").strip(), "codigo": str(datos.get("codigo") or "").strip(), "sede": str(datos.get("sede") or "").strip(), "dia": str(datos.get("dia") or "").strip(), "origen": str(datos.get("origen") or "").strip(), "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, } r = requests.post(f"{url}/rest/v1/{t}", headers=h, json=fila, timeout=30) r.raise_for_status() return r.json() # ── SUPABASE campanias: EDITAR una fila por id ── def editar_campania(self, cid, datos): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Prefer": "return=representation"} fila = { "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), "cargo": str(datos.get("cargo") or "").strip(), "codigo": str(datos.get("codigo") or "").strip(), "sede": str(datos.get("sede") or "").strip(), "dia": str(datos.get("dia") or "").strip(), "origen": str(datos.get("origen") or "").strip(), "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, } r = requests.patch(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, headers=h, json=fila, timeout=30) r.raise_for_status() return r.json() # ── SUPABASE campanias: BORRAR una fila por id ── def borrar_campania(self, cid): url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") h = {"apikey": key, "Authorization": f"Bearer {key}"} r = requests.delete(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, headers=h, timeout=30) r.raise_for_status() return {"id": cid, "borrado": True} # ── SQL SERVER: matrículas ── def traer_matriculas(self): sql = """ SELECT sgeca_matricula.num_matricula, sgeca_matricula.num_indice, sgeca_matricula.fch_matricula, sgeca_matricula.cod_estado AS estado_matricula, REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_1,' ',''),'+51',''),'+','') AS dsc_telefono_1, REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_2,' ',''),'+51',''),'+','') AS dsc_telefono_2, sgeca_matricula.cod_moneda, ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0)) FROM sgede_cronograma_matricula c WHERE c.cod_localidad = sgeca_matricula.cod_localidad AND c.num_matricula = sgeca_matricula.num_matricula AND c.num_refinanciamiento = 1), 0) AS INV_NETA, (SELECT rhuma_trabajador.dsc_apellido_paterno + ' ' + SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' + rhuma_trabajador.dsc_nombres FROM rhuma_trabajador WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor) AS dsc_vendedor, sgeca_programa.dsc_programa, sgede_RP_programa.dsc_det_programa AS dsc_promocion, ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula AND sgede_cronograma_matricula.num_refinanciamiento = 1 AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula, ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula AND sgede_cronograma_matricula.num_refinanciamiento = 1 AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1 FROM sgeca_matricula INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno WHERE sgeca_matricula.cod_localidad LIKE 'SCENT' AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000' AND sgeca_matricula.cod_estado <> 'ANU' """ conn = self.sql_conn() try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] rows = cur.fetchall() finally: conn.close() return [dict(zip(cols, r)) for r in rows]