Dashboard Leads - version limpia para servidor (.env y config de despliegue restaurados)
This commit is contained in:
894
backend/leads_logic.py
Normal file
894
backend/leads_logic.py
Normal 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: -agg[x]): # mayor a menor por cantidad
|
||||
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}
|
||||
Reference in New Issue
Block a user