895 lines
44 KiB
Python
895 lines
44 KiB
Python
# 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}
|