# backend/data_manager.py """ Capa de acceso a datos del módulo LEADS. - PostgreSQL (Chatwoot): leads de pauta (consolidado en UNA sola consulta). - SQL Server (Académico): matrículas y cursos. - GitHub: mapa de campañas (frases -> programa/sede), config editable. """ import os import json import requests from datetime import datetime from dotenv import load_dotenv load_dotenv() class DataManager: def __init__(self): # PostgreSQL (Chatwoot) 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") # SQL Server (Académico) 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", "") self.sql_driver = os.getenv("SQL_DRIVER", "{SQL Server}") # GitHub base = os.getenv("GITHUB_BASE", "") self.github_campanias_url = f"{base}/campanias.json" if base else "" self.campanias = [] self._cargar_campanias() # ── Conexiones ────────────────────────────────────────────── 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 if "FreeTDS" in self.sql_driver: conn_str = ( f"DRIVER={self.sql_driver};" f"SERVER={self.sql_server};" f"PORT=1433;" f"DATABASE={self.sql_db};" f"UID={self.sql_user};" f"PWD={self.sql_pass};" f"TDS_Version=7.3;" f"ClientCharset=UTF-8;" ) else: conn_str = ( f"DRIVER={self.sql_driver};SERVER={self.sql_server};" f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}" ) return pyodbc.connect(conn_str) # ── Config de campañas (GitHub, opcional) ─────────────────── def _cargar_campanias(self): if not self.github_campanias_url: self.campanias = [] return try: r = requests.get(self.github_campanias_url, timeout=5) r.raise_for_status() data = r.json() self.campanias = data.get("campanias", []) if isinstance(data, dict) else data except Exception: self.campanias = [] # ════════════════════════════════════════════════════════════ # CHATWOOT — UN SOLO QUERY CONSOLIDADO DE LEADS # Reemplaza los 4 queries del PBI (procesados, asignados, # contactados, etiquetas) por uno solo. El resto se calcula # en Python (Cantidad_Veces, Ultima_Etiqueta, Estado/Objeción). # ════════════════════════════════════════════════════════════ def traer_leads_chatwoot(self): """Leads de PAUTA (igual que el PBI): un mensaje cuenta como lead solo si su texto coincide con una FRASE de campaña y está dentro del rango de fechas de esa campaña. El mapa de campañas está incrustado (como en el Power BI).""" sql = """ SELECT DISTINCT ON (m.id) REGEXP_REPLACE(c.phone_number, '[^0-9]', '', 'g') AS telefono, u.name AS asesor, (m.created_at - INTERVAL '5 hours') 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 ('📚 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'), ('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') ) 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, m.created_at ASC """ conn = self.pg_conn() cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] conn.close() return filas # ════════════════════════════════════════════════════════════ # SQL SERVER — CURSOS (igual que el PBI: Fact_SQL_Base_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() cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] conn.close() return filas # ════════════════════════════════════════════════════════════ # SQL SERVER — MATRÍCULAS (Fact_SQL_Base_Matriculas, resumido) # ════════════════════════════════════════════════════════════ 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(sgema_alumno.dsc_telefono_1,' ','') AS dsc_telefono_1, 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() cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] filas = [dict(zip(cols, row)) for row in cur.fetchall()] conn.close() return filas # --- fin data_manager ---