Files
dashboard-leads/backend/data_manager_v2.py

725 lines
40 KiB
Python

# backend/data_manager.py (v2 - reescrito completo)
"""Capa de acceso a datos del módulo LEADS."""
import os
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'),
]
def _values_sql():
"""Construye el bloque VALUES (...) del mapa de campañas para el JOIN."""
filas = []
for frase, cargo, codigo, sede, dia, origen, fi, ff in CAMPANIAS:
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
# Usar un alias de servidor definido en /etc/freetds/freetds.conf (SQLSERVER_BDUS)
# en lugar de la IP directa, para que pyodbc/ODBC encuentre la configuración de FreeTDS.
conn_str = (
f"DRIVER={{SQL Server}};SERVER=SQLSERVER_BDUS;"
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()
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 ──
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
# ── 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: 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
headers = {"apikey": key, "Authorization": f"Bearer {key}"}
paso, desde = 1000, 0
try:
while True:
r = requests.get(
f"{url}/rest/v1/{t}",
params={"select": "telefono,sede,programa,fecha_creada,asesor,es_origen,canal",
"offset": str(desde), "limit": str(paso)},
headers=headers, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
out.extend(data)
if len(data) < paso:
break
desde += paso
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
# ── 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
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()
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
# ── 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()
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
# ── 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
# ── 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()
cur = conn.cursor()
cur.execute(sql)
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
conn.close()
return [dict(zip(cols, r)) for r in rows]