Diagnóstico y fix SQL Server: SERVERNAME, TDS_Version, Puerto explicitos

This commit is contained in:
desoladorxx
2026-07-22 16:11:54 -05:00
commit cd18c4ca5e
48 changed files with 8389 additions and 0 deletions

894
backend/leads_logic.py Normal file
View File

@@ -0,0 +1,894 @@
# backend/leads_logic.py
"""
Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python.
Columnas calculadas replicadas:
- Cantidad_Veces -> 1ª aparición de un teléfono = lead único
- Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list
- Tipo_Programa / Sede -> clasificación por dsc_programa
Medidas replicadas:
- Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos,
Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados,
Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad,
Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día,
tabla Estado/Objeción.
"""
from datetime import datetime, date
# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX)
ETIQUETAS_SISTEMA = {
"atención_humana", "negociación", "supervisor", "grupo_arequipa",
"grupo_trujillo", "sin_respuesta",
}
META_POR_TIPO = {
"PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22,
"PROVINCIA TEAC": 18, "PROVINCIA TERC": 18,
"SEMINARIOS": 15,
}
# ── Helpers de fecha ────────────────────────────────────────────
def _to_date(v):
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
s = str(v)[:10]
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).date()
except Exception:
pass
return None
def _en_periodo(d, ano, mes, dia):
"""True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar)."""
if d is None:
return False
if ano not in ("TODOS", None) and d.year != int(ano):
return False
if mes not in ("TODOS", None) and d.month != int(mes):
return False
if dia not in ("TODOS", None) and d.day != int(dia):
return False
return True
# ── Clasificadores (igual que columnas calculadas DAX) ──────────
def clasificar_tipo_programa(dsc_programa):
up = str(dsc_programa or "").upper()
otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS",
"TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"]
for o in otros:
if o.upper() in up:
return "OTROS"
return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo
def clasificar_sede(dsc_programa):
up = str(dsc_programa or "").upper()
if "AREQUIPA" in up: return "AREQUIPA"
if "TRUJILLO" in up: return "TRUJILLO"
if "PIURA" in up: return "PIURA"
return "LIMA"
def sede_por_codigo_map():
"""{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a
cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads,
para que coincida con 'sede del código' (no con sede_act)."""
from data_manager_v2 import CAMPANIAS
out = {}
for fila in CAMPANIAS:
cod = str(fila[2]).strip() # índice 2 = codigo
sede = str(fila[3]).strip().upper() # índice 3 = sede
if cod:
out[cod] = sede
return out
# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ──
def grupo_programa_curso(dsc_programa):
"""Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas).
TEAC = PROGRAMAS TEAC + PROVINCIA TEAC
TERC = PROGRAMAS TERC + PROVINCIA TERC
SEMINARIOS = SEMINARIOS
OTROS = OTROS + CARRERA"""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"):
return "TEAC"
if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"):
return "TERC"
if tp == "SEMINARIOS":
return "SEMINARIOS"
return "OTROS" # OTROS + CARRERA
def grupo_programa_lead(cargo):
"""Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas).
TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS."""
c = str(cargo or "").strip().upper()
if c == "TEAC":
return "TEAC"
if c == "TERC":
return "TERC"
return "SEMINARIOS"
def grupo_tipo_cohorte(tp):
"""Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede:
TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS."""
t = str(tp or "").upper()
if "TEAC" in t:
return "TEAC"
if "TERC" in t:
return "TERC"
return "SEMINARIOS"
# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR;
# si el asesor no está en la lista, usa la sede de la campaña.
_SEDE_POR_ASESOR = {
"almendra peralta": "Arequipa",
"juan carlos aguilar": "Piura",
"diego lázaro": "Trujillo",
"diego lazaro": "Trujillo",
"verónica la rosa": "Lima",
"veronica la rosa": "Lima",
"dayana balabarca": "Lima",
"milagros vargas": "Lima",
"carmen montoya": "Lima",
"diana chávez": "Lima",
"diana chavez": "Lima",
"copito rivera": "Lima",
}
def sede_act(asesor, sede_campania):
return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania)
def tipo_programa_cohorte(sede, programa_cat):
"""Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría."""
s = str(sede or "").upper()
p = str(programa_cat or "").upper()
if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC"
if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC"
return "SEMINARIOS"
def tipo_programa_curso(dsc_programa):
"""Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos).
Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa)."""
p = str(dsc_programa or "")
def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual
return s in p
# 1) OTROS
if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS")
or has("TALLER DE REFRIGERACIÓN DOM")
or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")):
return "OTROS"
if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG")
or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER")
or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA")
or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")):
return "OTROS"
# 2) CARRERA
if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"):
return "CARRERA"
# 3) PROVINCIA TEAC
if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROVINCIA TEAC"
# 4) PROVINCIA TERC
if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")):
return "PROVINCIA TERC"
# 5) SEMINARIOS
if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO")
or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")):
return "SEMINARIOS"
# 6) PROGRAMAS TEAC (Lima)
if (has("MANTENIMIENTO EN AIRE ACONDICIONADO")
or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)")
or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROGRAMAS TEAC"
# 7) PROGRAMAS TERC (Lima)
if (has("INSTALACION EN REFRIGERACION COMERCIAL")
or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)")
or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")):
return "PROGRAMAS TERC"
return "SEMINARIOS"
def meta_curso(dsc_programa):
"""Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso):
PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15."""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"):
return 22
if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"):
return 18
return 15
# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ──
# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de
# sistema, y luego reduce combinaciones concatenadas a un estado final.
_SUST = [
("atención_humana", ""), ("negociación", ""), ("supervisor", ""),
("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""),
("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"),
("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""),
("pendiente", ""),
("revisando_informaciónsólo_consulta", "sólo_consulta"),
("contacto_iniciadovendido", "vendido"),
("revisando_informacióninteresado", "interesado"),
("revisando_informaciónvendido", "vendido"),
("sólo_consultainteresado", "interesado"),
("revisando_informaciónno_califica", "no_califica"),
("interesadopor_pagar", "por_pagar"),
("revisando_informaciónprecio_elevado", "precio_elevado"),
("proxima_fechavendido", "vendido"),
("sólo_consultavendido", "vendido"),
("vendidointeresado", "vendido"),
("revisando_informaciónproxima_fecha", "proxima_fecha"),
("contacto_iniciadosólo_consulta", "sólo_consulta"),
]
def estado_objecion(etiquetas):
"""Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI)."""
if etiquetas is None:
return "no trabajado"
s = str(etiquetas).replace(",", "").replace(" ", "")
for buscar, reemplazar in _SUST:
s = s.replace(buscar, reemplazar)
s = s.strip()
return s if s else "no trabajado"
# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ──
_ULTIMA_NO_TRABAJADO = {
"", "(en blanco)", "aprobación", "atencion humana", "atención humana",
"grupo arequipa", "grupo trujillo", "importacion masiva", "inicio",
"negociacion", "sin respuesta", "pendiente",
}
def ultima_etiqueta(etiquetas):
if etiquetas is None or str(etiquetas).strip() == "":
return "NO TRABAJADO"
partes = str(etiquetas).split(", ")
limpia = partes[-1].replace("_", " ").strip()
if limpia in _ULTIMA_NO_TRABAJADO:
return "NO TRABAJADO"
return limpia if limpia else "NO TRABAJADO"
# ════════════════════════════════════════════════════════════════
# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa)
# ════════════════════════════════════════════════════════════════
def procesar_leads(filas_chatwoot):
"""Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia.
Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'."""
# Ordenar por fecha ascendente (el query puede venir ordenado por id)
filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min))
# Teléfonos de prueba a excluir (igual que el PBI: 924374783)
EXCLUIR = {"924374783"}
vistos = set()
out = []
for f in filas_chatwoot:
tel = str(f.get("telefono") or "").strip()
if not tel or tel in EXCLUIR:
continue
es_unico = tel not in vistos
vistos.add(tel)
fecha = _to_date(f.get("fecha_creada"))
asesor = (f.get("asesor") or "").strip()
sede_campania = (f.get("sede") or "").strip() or "SIN SEDE"
sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor)
cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas)
out.append({
"telefono": tel,
"asesor": asesor,
"fecha": fecha,
"estado": estado_objecion(f.get("etiquetas")),
"ultima": ultima_etiqueta(f.get("etiquetas")),
"sede": sede,
"cargo": cargo, # TEAC / TERC / VRF / CO2 / etc.
"tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS
"codigo": (f.get("codigo") or "").strip() or "-", # código de campaña
"es_unico": es_unico,
})
return out
# ════════════════════════════════════════════════════════════════
# KPIs DEL MÓDULO LEADS
# ════════════════════════════════════════════════════════════════
def kpis_leads(leads, cursos, matriculas, ano, mes, dia):
# Leads en el periodo (solo únicos = Cantidad_Veces == 1)
leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
recibidos = len(leads_periodo)
procesados = sum(1 for l in leads_periodo if l["asesor"])
contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO")
pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0
pct_contactados = (contactados / procesados) if procesados > 0 else 0.0
# ── Cursos del periodo (por fecha de inicio) ──
cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)]
cursos_programados = len(cursos_periodo)
suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS")
hoy = date.today()
iniciados = sum(1 for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and (_to_date(c.get("fch_inicio")) or hoy) <= hoy)
# ── Total matriculados (= medida Matriculas_Totales del PBI) ──
# Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes,
# excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula.
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
total_matriculados = sum(
1 for m in matriculas
if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)
)
# Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta).
# Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente.
# (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase)
CURSOS_EXCLUIDOS = {"1154", "1121"}
cursos_ocup = [c for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS]
sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup)
sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup)
# ── Matrículas en los cursos del mes ──
# Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y
# (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa
# de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI.
promos_periodo = {str(c.get("dsc_det_programa", "")).strip()
for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()}
mats_mes = [m for m in matriculas
if str(m.get("dsc_promocion", "")).strip() in promos_periodo
and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)]
matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes})
ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0
return {
"leads_recibidos": recibidos,
"leads_procesados": procesados,
"leads_contactados": contactados,
"pct_procesados": round(pct_procesados * 100, 2),
"pct_contactados": round(pct_contactados * 100, 2),
"total_matriculados": total_matriculados,
"matriculas_mes": matriculas_mes,
"ocupabilidad": round(ocupabilidad * 100, 2),
"cursos_programados": cursos_programados,
"cursos_reprogramados": 0, # requiere SharePoint (fase 2)
"cursos_suspendidos": suspendidos,
"cursos_iniciados": iniciados,
}
# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ──
def tabla_estado_objecion(leads, ano, mes, dia):
leads_periodo = [l for l in leads if l["es_unico"]
and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)]
agg = {} # estado -> total
por_asesor = {} # estado -> {asesor: cantidad}
telefonos = {} # (estado, asesor) -> [telefonos]
for l in leads_periodo:
est = l["ultima"] or "NO TRABAJADO"
ase = l["asesor"] or "SIN ASESOR"
agg[est] = agg.get(est, 0) + 1
por_asesor.setdefault(est, {})
por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1
telefonos.setdefault((est, ase), []).append(l["telefono"])
filas = []
for estado in sorted(agg.keys(), key=lambda x: x.lower()):
asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1])
filas.append({
"estado": estado,
"cantidad": agg[estado],
"asesores": [
{"asesor": a, "cantidad": c,
"telefonos": sorted(telefonos.get((estado, a), []))}
for a, c in asesores
],
})
total = sum(agg.values())
return {"filas": filas, "total": total}
# ── Matrículas por día (gráfico de línea) ──
# Cuenta TODAS las matrículas del día (misma base que Total Matriculados):
# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula.
def matriculas_por_dia(matriculas, cursos, ano, mes, dia):
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
conteo = {} # dia -> total
por_tipo = {} # dia -> {tipo_programa: cantidad}
for m in matriculas:
if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"):
continue
if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS:
continue
d = _to_date(m.get("fch_matricula"))
if not _en_periodo(d, ano, mes, dia):
continue
conteo[d.day] = conteo.get(d.day, 0) + 1
tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS
por_tipo.setdefault(d.day, {})
por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1
def _detalle(day):
items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1])
return [{"tipo": t, "cantidad": c} for t, c in items]
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)}
for d in range(1, ndias + 1)]
return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)}
for d in sorted(conteo.keys())]
# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ──
def leads_por_dia(leads, ano, mes, dia):
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
tot = {} # dia -> totales
proc = {} # dia -> procesados (con asesor)
for l in lp:
d = l["fecha"].day
tot[d] = tot.get(d, 0) + 1
if l["asesor"]:
proc[d] = proc.get(d, 0) + 1
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
dias = range(1, ndias + 1)
else:
dias = sorted(set(list(tot.keys()) + list(proc.keys())))
return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias]
# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ──
def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None,
resultados_por_pauta_periodo=None, campanias_sede_cargo=None):
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
resultados_por_pauta_periodo = resultados_por_pauta_periodo or {}
campanias_sede_cargo = campanias_sede_cargo or {}
# 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
# mapa teléfono -> fecha del lead (para "matrícula posterior al lead")
fecha_lead = {}
for l in lp:
fecha_lead.setdefault(l["telefono"], l["fecha"])
# 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead
# (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte)
mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float}
for m in matriculas:
tel = str(m.get("dsc_telefono_1") or "").strip()
if tel not in fecha_lead:
continue
fm = _to_date(m.get("fch_matricula"))
fl = fecha_lead[tel]
if not fm or not fl or not (fm > fl):
continue
# INV_NETA_FINAL: si DOL, ×3.34
try: inv = float(m.get("INV_NETA", 0) or 0)
except: inv = 0.0
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
inv *= 3.34
e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0})
e["mats"].add(str(m.get("num_matricula")))
e["inv"] += inv
# 3) Agrupar por Sede -> Tipo_Programa -> Código
def _nuevo():
return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0}
grupos = {} # sede -> tipo -> codigo -> métricas
for l in lp:
sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"]
g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo())
g["recibidos"] += 1
if l["asesor"]:
g["procesados"] += 1
if tel in mat_por_tel:
g["mats"] |= mat_por_tel[tel]["mats"]
g["inv"] += mat_por_tel[tel]["inv"]
def _fila(d):
return {"recibidos": d["recibidos"], "procesados": d["procesados"],
"matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)}
# IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS,
# una sola vez por pauta (no por sede del lead), para NO duplicar.
# imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados
imp_st = {}
res_st = {}
for cod, imp_val in importe_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0)
for cod, res_val in resultados_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0)
filas = []
for sede in sorted(grupos.keys()):
subfilas = []
s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0
for tp in sorted(grupos[sede].keys()):
cods = grupos[sede][tp]
imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo
res_map = res_st.get((sede.upper(), tp), {})
# métricas del tipo (sumando sus códigos)
t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0
codigos = []
for cod in sorted(cods.keys()):
d = cods[cod]
imp_cod = float(imp_map.get(str(cod).strip(), 0.0))
res_cod = int(res_map.get(str(cod).strip(), 0))
codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2),
"resultados": res_cod})
t_rec += d["recibidos"]; t_proc += d["procesados"]
t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod
subfilas.append({
"tipo": tp, "recibidos": t_rec, "procesados": t_proc,
"matriculas": len(t_mats), "inversion": round(t_inv, 0),
"importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos,
})
s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res
filas.append({
"sede": sede, "recibidos": s_rec, "procesados": s_proc,
"matriculas": len(s_mats), "inversion": round(s_inv, 0),
"importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas,
})
# Totales
t_rec = sum(f["recibidos"] for f in filas)
t_proc = sum(f["procesados"] for f in filas)
t_inv = round(sum(f["inversion"] for f in filas), 0)
# Total de importe/resultados: suma de TODAS las pautas que estan en campanias
# (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde.
t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo), 2)
t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo)
# matrículas total: unión global
all_mats = set()
for tel, e in mat_por_tel.items():
all_mats |= e["mats"]
return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc,
"matriculas": len(all_mats), "inversion": t_inv,
"importe": t_imp, "resultados": t_res}}
# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ──
# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos.
_PERS_REEMPLAZOS = [
("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"),
("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"),
("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"),
("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"),
("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"),
("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"),
("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"),
("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"),
("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"),
("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"),
("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"),
("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"),
("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"),
("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"),
("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"),
("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"),
("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"),
("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"),
# Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final)
("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"),
("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"),
("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"),
("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"),
# Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros)
("VIRTUAL - DIMENSIONAMIENTO DE", ""),
("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""),
("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"),
("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"),
# Reemplazos finales (sedes/carreras/masterclass)
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"),
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"),
("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"),
("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"),
]
def personalizado_curso(dsc_programa, cod_frecuencia=""):
"""Replica Fact_SQL_Base_Cursos[Personalizado] del PBI:
base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa)."""
s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}"
# Text.Clean + reemplazar saltos de línea por espacio, luego Trim
s = s.replace("\r", " ").replace("\n", " ")
s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control)
s = s.strip()
for buscar, reemplazo in _PERS_REEMPLAZOS:
s = s.replace(buscar, reemplazo)
# colapsar espacios múltiples que pudieran quedar de reemplazos vacíos
s = " ".join(s.split())
return s
# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ──
# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de
# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente.
# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio.
def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None,
campanias_map=None, cartera_all=None, cartera_asig=None,
mat_por_indice=None, cartera_tels=None, cartera_origen=None,
importe_por_pauta=None, importe_por_pauta_periodo=None,
cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None):
pauta_map = pauta_map or {}
pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos)
campanias_map = campanias_map or {}
cartera_all = cartera_all or {}
cartera_asig = cartera_asig or {}
mat_por_indice = mat_por_indice or {}
cartera_tels = cartera_tels or set()
cartera_origen = cartera_origen or {}
importe_por_pauta = importe_por_pauta or {}
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]}
# cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal
cartera_canal = cartera_canal or {}
CURSOS_EXCLUIDOS = {"1121", "1154"}
def _excluir(c):
ni = str(c.get("num_indice", ""))
if ni in CURSOS_EXCLUIDOS:
return True
info = pauta_map.get(ni)
if not info:
return False # sin info en Supabase → no se excluye
# Excluir SUSPENDIDO o contar = NO
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
cursos_periodo = [c for c in cursos
if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)
and not _excluir(c)]
# Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha).
leads_unicos = [l for l in leads if l.get("es_unico")]
hist_por_cod = {} # codigo → set teléfonos
hist_ases_cod = {} # codigo → set teléfonos con asesor
mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado)
mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo)
for l in leads_unicos:
cod = l.get("codigo", "-")
tel = l["telefono"]
hist_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
hist_ases_cod.setdefault(cod, set()).add(tel)
if _en_periodo(l["fecha"], ano, mes, dia):
mes_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
mes_ases_cod.setdefault(cod, set()).add(tel)
# Construir filas con sus medidas
pre = []
for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)):
nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
fch = _to_date(c.get("fch_inicio"))
ni = str(c.get("num_indice", "")).strip()
# LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva,
# usa la de basebi (compatibilidad).
cods = pautas_por_indice.get(ni)
if not cods:
_info = pauta_map.get(ni)
cods = [_info.get("pauta")] if _info and _info.get("pauta") else []
cods = [str(x).strip() for x in cods if x]
# Leads = union de telefonos de TODAS las pautas (sin duplicar)
def _union(mapa):
s = set()
for cd in cods:
s |= mapa.get(cd, set())
return len(s)
nuevos = _union(hist_por_cod)
nuevos_a = _union(hist_ases_cod)
nuevos_m = _union(mes_por_cod)
nuevos_ma = _union(mes_ases_cod)
pre.append({
"num_indice": ni,
"personalizado": nombre,
"fch": fch,
"pauta": cods[0] if cods else None, # pauta principal (para mostrar)
"pautas": cods, # todas las pautas (para sumar)
"fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "",
"leads_nuevos": nuevos,
"leads_nuevos_asesor": nuevos_a,
"leads_nuevos_mes": nuevos_m,
"leads_nuevos_mes_asesor": nuevos_ma,
})
# Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por
# personalizado → lista de (fecha, leads_nuevos). Para el acumulado.
hist_cursos = {}
for c in cursos:
if _excluir(c):
continue
nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
f = _to_date(c.get("fch_inicio"))
info = pauta_map.get(str(c.get("num_indice", "")))
cd = info.get("pauta") if info else None
nv = len(hist_por_cod.get(cd, ())) if cd else 0
hist_cursos.setdefault(nom, []).append((f, nv))
# Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del
# MISMO personalizado con fecha <= la del curso actual (histórico completo).
filas = []
for r in pre:
acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], [])
if f and r["fch"] and f <= r["fch"])
# CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA
# del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso.
# cartera_total = todos; cartera_total_asig = solo con asesor asignado.
cartera_total = 0
cartera_total_asig = 0
cod = r.get("pauta")
cods = r.get("pautas") or ([cod] if cod else [])
if cods and r["fch"]:
# Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice
tels_all = {}; tels_asig = {}
for cd in cods:
sp = campanias_map.get(str(cd).upper())
if not sp:
continue
for tel, fx in cartera_all.get(sp, {}).items():
if fx and (tel not in tels_all or fx < tels_all[tel]):
tels_all[tel] = fx
for tel, fx in cartera_asig.get(sp, {}).items():
if fx and (tel not in tels_asig or fx < tels_asig[tel]):
tels_asig[tel] = fx
cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"])
cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"])
# MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono
# (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha.
_ni = r["num_indice"]
_ni = _ni[:-2] if _ni.endswith(".0") else _ni
_mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula)
_tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels]
matriculas_no_iden = len(_tels_no_iden)
# MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen
# del lead (registro es_origen=SI de ese teléfono en la cartera).
mat_leads_nuevos = 0
mat_leads_antiguos = 0
_tels_nuevos = []
_tels_antiguos = []
for (ph, fm) in _mats:
if not ph or not fm:
continue
forig = cartera_origen.get(ph)
if forig is None:
continue
dias = (fm - forig).days
if 0 <= dias <= 45:
mat_leads_nuevos += 1
_tels_nuevos.append(ph)
else:
# >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo
mat_leads_antiguos += 1
_tels_antiguos.append(ph)
# IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice.
importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2)
# IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado.
importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2)
# ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre
# (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera.
def _subfila(clave):
cc = cartera_canal.get(clave, {})
c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set())
ct = ct_asig = 0
l_rec = l_proc = 0
l_rec_mes = l_proc_mes = 0
if cod and r["fch"]:
sp = campanias_map.get(str(cod).upper())
if sp:
fa = c_all.get(sp, {})
fg = c_asig.get(sp, {})
# Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre)
ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"])
ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"])
# L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte)
l_rec = ct
l_proc = ct_asig
# "del Mes" = fecha_creada en el periodo filtrado
l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia))
l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia))
# Matrículas del curso, clasificadas por canal:
# - NO identificadas (tel NO en cartera) -> todas a OTROS.
# - identificadas -> al canal de su telefono (c_tels), regla 45 dias.
mt_noiden = mt_nuevos = mt_antiguos = 0
for (ph, fm) in _mats:
if not ph:
continue
en_cartera = ph in cartera_tels
if not en_cartera:
# no identificada: solo cuenta en la subfila OTROS
if clave == "OTROS":
mt_noiden += 1
continue
# identificada: solo cuenta si su tel pertenece a ESTE canal
if ph not in c_tels:
continue
forig = cartera_origen.get(ph)
if fm and forig:
d = (fm - forig).days
if 0 <= d <= 45: mt_nuevos += 1
else: mt_antiguos += 1
return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig,
"leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc,
"leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes,
"matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos,
"mat_leads_antiguos": mt_antiguos}
subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else []
filas.append({
"num_indice": r["num_indice"],
"personalizado": r["personalizado"],
"fecha_inicio": r["fecha_inicio"],
"pauta": r.get("pauta"),
"importe_pauta": importe_pauta,
"importe_pauta_mes": importe_pauta_mes,
"cartera_total": cartera_total,
"cartera_total_asig": cartera_total_asig,
"matriculas_no_iden": matriculas_no_iden,
"matriculas_no_iden_tels": _tels_no_iden,
"mat_leads_nuevos": mat_leads_nuevos,
"mat_leads_antiguos": mat_leads_antiguos,
"mat_leads_nuevos_tels": _tels_nuevos,
"mat_leads_antiguos_tels": _tels_antiguos,
"leads_acumulados": acum,
"leads_nuevos": r["leads_nuevos"],
"leads_nuevos_asesor": r["leads_nuevos_asesor"],
"leads_nuevos_mes": r["leads_nuevos_mes"],
"leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"],
"subfilas_canal": subfilas,
"conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])],
"contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI",
})
return {"filas": filas}