feat(reportes): rango salarial en buckets + script de importacion de afluencia

- bucketSalario: el texto libre del rango salarial ('$80k', '4,500 DLLS',
  '50-80 MIL') se agrupa en rangos por moneda para que la grafica sea
  legible; valores sin monto se omiten.
- scripts/importar-afluencia-agosto.py: importa el historial de
  afluencia al tablero Leads con normalizacion de catalogos, dedup por
  telefono y desregistro temporal del webhook create_item (para no
  disparar round-robin ni oportunidades con datos historicos).
This commit is contained in:
2026-09-02 04:46:18 +00:00
parent 4030faee25
commit 9ef86efb90
3 changed files with 521 additions and 2 deletions

View File

@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""Importa el historial de afluencia de agosto (AFLUENCIA AGOSTO.xlsx, hoja
'AGOSTO') al tablero Leads de Monday.
Uso:
python3 importar-afluencia-agosto.py <ruta.xlsx> # dry-run: solo reporta
python3 importar-afluencia-agosto.py <ruta.xlsx> --apply # importa de verdad
Decisiones:
- Solo la hoja AGOSTO (las demás son subconjuntos de la misma).
- Dedup por teléfono (últimos 10 dígitos) contra el board y dentro del archivo;
se conserva el registro MÁS TEMPRANO (primer contacto).
- Se importa directo vía GraphQL (no por el API del ecosistema) para NO disparar
round-robin, LeadCaptura ni oportunidades: es historial, no captura nueva.
Aun así el webhook create_item de Monday SÍ dispararía automatizaciones, por
eso con --apply el script lo DESREGISTRA antes y lo VUELVE A CREAR al final
(mismo evento y URL, leídos del propio webhook).
- La fecha original va en la columna 'Fecha' (Monday no permite escribir
created_at). Vendedor y estatus originales van prefijados en 'Notas'.
- Valores fuera de catálogo se normalizan a los labels sembrados; los
informativos-pero-no-categóricos ('NO QUISO DECIR', '?', etc.) se omiten.
"""
import json
import re
import sys
import time
import urllib.request
from pathlib import Path
import openpyxl
ENV_PATH = Path(__file__).resolve().parents[2] / '.env'
BOARD_LEADS = '18427678493'
API = 'https://api.monday.com/v2'
# ---------------- Monday ----------------
def token_desde_env() -> str:
m = re.search(r'^MONDAY_API_KEY="?([^"\n]+)"?', ENV_PATH.read_text(), re.M)
if not m:
sys.exit('No se encontró MONDAY_API_KEY en .env')
return m.group(1)
TOKEN = token_desde_env()
def gql(query: str, variables: dict | None = None) -> dict:
req = urllib.request.Request(
API,
data=json.dumps({'query': query, 'variables': variables or {}}).encode(),
headers={'Authorization': TOKEN, 'Content-Type': 'application/json'},
)
with urllib.request.urlopen(req) as r:
res = json.loads(r.read())
if 'errors' in res:
raise RuntimeError(res['errors'])
return res['data']
def columnas_board() -> dict[str, dict]:
data = gql('query { boards(ids: [%s]) { columns { id title type settings_str } } }' % BOARD_LEADS)
return {c['title']: c for c in data['boards'][0]['columns']}
def labels_status(col: dict) -> list[str]:
if col['type'] != 'status':
return []
settings = json.loads(col.get('settings_str') or '{}')
return [v for v in settings.get('labels', {}).values() if v]
def sembrar_label(col: dict, label: str) -> None:
"""Crea un label faltante en una columna status vía item temporal."""
item = gql('mutation { create_item(board_id: %s, item_name: "TEMP_SEED") { id } }' % BOARD_LEADS)
item_id = item['create_item']['id']
try:
gql(
'mutation ($b: ID!, $i: ID!, $c: String!, $v: JSON!) { change_column_value(board_id: $b, item_id: $i, column_id: $c, value: $v, create_labels_if_missing: true) { id } }',
{'b': BOARD_LEADS, 'i': item_id, 'c': col['id'], 'v': json.dumps({'label': label})},
)
finally:
gql('mutation { delete_item(item_id: %s) { id } }' % item_id)
time.sleep(0.4)
def telefonos_existentes(col_tel_id: str) -> set[str]:
"""Últimos 10 dígitos de todos los teléfonos ya cargados en el board."""
phones: set[str] = set()
cursor = None
while True:
if cursor is None:
data = gql(
'query { boards(ids: [%s]) { items_page(limit: 200) { cursor items { column_values(ids: ["%s"]) { text } } } } }'
% (BOARD_LEADS, col_tel_id)
)
else:
data = gql(
'query ($c: String!) { boards(ids: [%s]) { items_page(limit: 200, cursor: $c) { cursor items { column_values(ids: ["%s"]) { text } } } } }'
% (BOARD_LEADS, col_tel_id),
{'c': cursor},
)
page = data['boards'][0]['items_page']
for item in page['items']:
tel = item['column_values'][0]['text'] if item['column_values'] else ''
clave = clave_telefono(tel)
if clave:
phones.add(clave)
cursor = page['cursor']
if not cursor:
return phones
def webhook_create_item() -> dict | None:
# Los webhooks se consultan en raíz (no es campo de Board en esta versión)
data = gql('query { webhooks(board_id: %s) { id event config } }' % BOARD_LEADS)
for wh in data['webhooks']:
if wh['event'] == 'create_item':
return wh
return None
def borrar_webhook(wh_id: str) -> None:
gql('mutation { delete_webhook(id: %s) { id board_id } }' % wh_id)
def crear_webhook(url: str) -> str:
data = gql(
'mutation ($b: ID!, $u: String!) { create_webhook(board_id: $b, url: $u, event: create_item) { id board_id } }',
{'b': BOARD_LEADS, 'u': url},
)
return data['create_webhook']['id']
# ---------------- Normalización ----------------
def clave_telefono(tel: str) -> str | None:
d = re.sub(r'\D', '', tel or '')
return d[-10:] if len(d) >= 10 else None
def telefono_limpio(tel: str) -> str:
return re.sub(r'\D', '', tel or '')
def norm_desarrollo(v: str) -> str:
s = v.strip().upper()
return {'SOLE': 'SOLÉ', 'SOLÉ': 'SOLÉ'}.get(s, s)
def norm_estado(v: str) -> str:
s = v.strip().upper()
if s in ('NO CALIFICA', 'NO INTERESA', 'NO LE GUSTO TANTO', 'PERDIDA') or s.startswith('NO PERFILADO'):
return 'DESCARTADO'
if s in ('APARTADO', 'APARTÓ', 'APARTO', 'NEGOCIACION'):
return 'CALIFICADO'
if s in ('VINO A CITA', 'ASISTIÓ', 'VISITO', 'VISITÓ'):
return 'AGENDA CITA'
return 'CONTACTADO' # SEGUIMIENTO, DEFINICION, ABIERTO, PERFILADO, LE GUSTO...
ORIGEN_MAP = [
(r'FACE|FAN PAGE', 'Facebook'),
(r'INSTAGRAM', 'Instagram'),
(r'BROKER|EXTERNO', 'Broker'),
(r'MARATON|LISTADO|RE ?CONTACTO|BASE DE DATOS', 'Base de datos'),
(r'PROP|CAMBACEO', 'Prospección'),
(r'WHATS ?APP|WZP', 'WhatsApp'),
(r'INTERNET|WEB', 'Web'),
]
def norm_origen(v: str) -> str | None:
s = v.strip().upper()
if s in ('', '-'):
return None
for patron, label in ORIGEN_MAP:
if re.search(patron, s):
return label
return 'Otro' # MIS REDES, REDES SOCIALES, PASE...
MEDIO_MAP = [
(r'^MESA( DE)? CONTROL$', 'Mesa de control'),
(r'WZP|WHATS ?APP', 'WhatsApp'),
(r'^LLAMADA$', 'Llamada'),
(r'BROKER', 'Broker'),
(r'PASE DE GUARDIA|SEGUIMIENTO DE UN PASE|^PASE$', 'Pase de guardia'),
(r'PROSPECCI[ÓO]N', 'Prospección asesor'),
(r'MIS REDES', 'Mis redes'),
(r'FAN PAGE', 'Fan page'),
(r'CAMBACEO', 'Cambaceo'),
]
def norm_medio(v: str) -> str | None:
s = re.sub(r'\s+', ' ', v.strip())
if s == '':
return None
for patron, label in MEDIO_MAP:
if re.search(patron, s.upper()):
return label
return s.capitalize()
def norm_edad(v: str) -> int | None:
s = str(v).strip()
if s == '':
return None
m = re.match(r'^(\d{2})\s*[-aA]\s*(\d{2})', s) # rangos '30-40', '40-30' → punto medio
if m:
return (int(m.group(1)) + int(m.group(2))) // 2
m = re.match(r'^(\d{2})', s) # '28 años'
if m:
edad = int(m.group(1))
return edad if 15 <= edad <= 120 else None
return None
def norm_estado_civil(v: str) -> str | None:
s = v.strip().upper()
if s.startswith('SOLTER') or s in ('SLTERA',):
return 'Soltero(a)'
if s.startswith('CASADO'):
return 'Casado(a)'
if s in ('UL', 'EN PAREJA', 'UNION LIBRE', 'UNIÓN LIBRE'):
return 'Unión libre'
if s.startswith('DIVORCIAD'):
return 'Divorciado(a)'
if s.startswith('VIUD'):
return 'Viudo(a)'
return None # NO DIJO, vacío
def norm_tipo_empleo(v: str) -> str | None:
s = v.strip().upper()
if s in ('EMPLEADO', 'ASALARIADO'):
return 'Asalariado'
if s == 'INDEPENDIENTE':
return 'Independiente'
return None # N/A, edades coladas, vacío
def norm_credito(v: str) -> str | None:
s = v.strip().upper()
if 'COFINAVIT' in s:
return 'Cofinavit'
if 'APOYO INFONAVIT' in s:
return 'Apoyo Infonavit'
if 'INFO' in s:
return 'Infonavit'
if 'FOVISS' in s:
return 'Fovissste'
if s in ('CONTADO', 'RECURSO PROPIO'):
return 'Contado'
if 'BANC' in s or 'HIPOTEC' in s or 'FIDEICOMISO' in s or 'EFECTIVO' in s:
return 'Bancario'
return None # NO, SI, BAJO, ?, EMPLEADO, vacío
def norm_economia(v: str) -> str | None:
s = v.strip().upper()
if s in ('MIXTA', 'MEXICANA/MEXICANA'):
return 'Mixta'
if 'AMERICANA' in s or 'AMARICANA' in s:
return 'Economía americana'
if s in ('MEXICANA', 'MEXICANO', 'NACIONAL', 'INDEXADA'):
return 'Economía mexicana'
return None
def norm_califica(v: str) -> str | None:
s = v.strip().upper()
if s.startswith('SI') or s.startswith('SÍ') or 'INFORMÓ QUE SI' in s:
return 'Sí'
if s.startswith('NO') or 'BURO' in s or 'BURÓ' in s:
return 'No'
if s in ('PENDIENTE', 'POR DEFINIR', '?', '??'):
return 'Por confirmar'
return None
RANGO_SALARIAL_OMITIR = {'NO QUISO DECIR', '?', 'JUBIALDO'}
def norm_rango(v: str) -> str | None:
s = re.sub(r'\s+', ' ', str(v).strip())
if s == '' or s.upper().startswith('NO QUISO') or s.upper() in RANGO_SALARIAL_OMITIR:
return None
return s
def norm_lo_quiere(v: str) -> str | None:
s = v.strip().upper()
if s.startswith('INVERSION') or s.startswith('INVERSIÓN'):
return 'Inversión'
if s.startswith('VIVIR'):
return 'Vivir'
return None
# ---------------- Parseo ----------------
def parsear(path: str) -> list[dict]:
ws = openpyxl.load_workbook(path, data_only=True)['AGOSTO']
registros = []
for row in ws.iter_rows(min_row=2, values_only=True):
if row[3] is None or str(row[3]).strip() == '':
continue
fecha = row[1]
observaciones = str(row[7] or '').strip()
email = str(row[11] or '').strip().lower()
if email and not re.match(r'^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$', email):
# Monday rechaza correos no ASCII o texto libre ('SIN AFLUENCIA'):
# se preserva en Notas y el item queda sin Email
observaciones = f'Correo original: {email}. {observaciones}'.strip()
email = ''
registros.append({
'desarrollo': norm_desarrollo(str(row[0] or '')),
'fecha': fecha.strftime('%Y-%m-%d') if hasattr(fecha, 'strftime') else None,
'nombre': re.sub(r'\s+', ' ', str(row[3]).strip()),
'telefono': telefono_limpio(str(row[4] or '')),
'vendedor': re.sub(r'\s+', ' ', str(row[5] or '').strip()),
'estatusOriginal': re.sub(r'\s+', ' ', str(row[6] or '').strip()),
'observaciones': observaciones,
'medioContacto': norm_medio(str(row[9] or '')),
'origen': norm_origen(str(row[10] or '')),
'email': email or None,
'edad': norm_edad(str(row[12] or '')),
'estadoCivil': norm_estado_civil(str(row[13] or '')),
'tipoEmpleo': norm_tipo_empleo(str(row[14] or '')),
'ocupacion': str(row[15] or '').strip() or None,
'rangoSalarial': norm_rango(str(row[16] or '')),
'credito': norm_credito(str(row[17] or '')),
'tipoEconomia': norm_economia(str(row[18] or '')),
'califica': norm_califica(str(row[19] or '')),
'loQuierePara': norm_lo_quiere(str(row[20] or '')),
'estado': norm_estado(str(row[6] or '')),
})
registros.sort(key=lambda r: r['fecha'] or '9999')
return registros
# ---------------- Importación ----------------
def valor_por_tipo(col: dict, crudo) -> object:
t = col['type']
if t == 'status':
return {'label': str(crudo)}
if t == 'numbers':
return float(crudo) if not isinstance(crudo, int) else crudo
if t == 'date':
return {'date': str(crudo)}
if t == 'email':
return {'email': str(crudo), 'text': str(crudo)}
if t == 'phone':
return {'phone': str(crudo), 'countryShortName': 'MX'}
return str(crudo)
def crear_lead(cols: dict[str, dict], r: dict) -> str:
notas_partes = []
if r['vendedor']:
notas_partes.append(f"Vendedor: {r['vendedor']}")
if r['estatusOriginal']:
notas_partes.append(f"Estatus ago-2026: {r['estatusOriginal']}")
if r['observaciones']:
notas_partes.append(r['observaciones'])
campos = {
'Estado': r['estado'],
'Teléfono': r['telefono'] or None,
'Email': r['email'],
'Origen': r['origen'],
'Desarrollo': r['desarrollo'] or None,
'Fecha': r['fecha'],
'Medio de Contacto': r['medioContacto'],
'Edad': r['edad'],
'Estado Civil': r['estadoCivil'],
'Tipo de Empleo': r['tipoEmpleo'],
'Ocupación': r['ocupacion'],
'Rango Salarial': r['rangoSalarial'],
'Crédito': r['credito'],
'Tipo de Economía': r['tipoEconomia'],
'Califica': r['califica'],
'Lo Quiere Para': r['loQuierePara'],
'Notas': ' · '.join(notas_partes)[:1800] or None,
}
values = {}
for titulo, crudo in campos.items():
if crudo is None or crudo == '' or titulo not in cols:
continue
values[cols[titulo]['id']] = valor_por_tipo(cols[titulo], crudo)
data = gql(
'mutation ($b: ID!, $n: String!, $v: JSON!) { create_item(board_id: $b, item_name: $n, column_values: $v) { id } }',
{'b': BOARD_LEADS, 'n': r['nombre'], 'v': json.dumps(values)},
)
return data['create_item']['id']
def main() -> None:
apply = '--apply' in sys.argv
path = next(a for a in sys.argv[1:] if not a.startswith('--'))
cols = columnas_board()
print(f"Columnas del board: {len(cols)}")
# Labels que el historial necesita y aún no existen
faltantes = {'Desarrollo': ['SOLÉ'], 'Origen': ['Instagram', 'Broker', 'Base de datos', 'Prospección']}
for titulo, labels in faltantes.items():
actuales = labels_status(cols[titulo])
for label in labels:
if label not in actuales:
if apply:
sembrar_label(cols[titulo], label)
print(f"Label sembrado en '{titulo}': {label}")
else:
print(f"[dry-run] Faltaría label en '{titulo}': {label}")
registros = parsear(path)
existentes = telefonos_existentes(cols['Teléfono']['id'])
print(f"Teléfonos ya en el board: {len(existentes)}")
vistos: set[str] = set()
a_importar, duplicados = [], []
for r in registros:
clave = clave_telefono(r['telefono'])
if clave and (clave in existentes or clave in vistos):
duplicados.append(r)
continue
if clave:
vistos.add(clave)
a_importar.append(r)
print(f"\nRegistros en archivo: {len(registros)}")
print(f"A importar: {len(a_importar)} · duplicados omitidos: {len(duplicados)}")
for d in duplicados:
print(f" dup: {d['nombre'][:40]} ({d['telefono']})")
from collections import Counter
for campo in ['desarrollo', 'estado', 'origen', 'estadoCivil', 'tipoEmpleo', 'credito', 'tipoEconomia', 'califica', 'medioContacto', 'loQuierePara']:
c = Counter(r[campo] or '(vacío)' for r in a_importar)
print(f"\n{campo}: {dict(c.most_common())}")
if not apply:
print('\nDRY-RUN: nada se escribió. Corre con --apply para importar.')
return
# Desregistrar el webhook create_item para que el historial NO dispare
# round-robin ni oportunidades; se recrea al final con la misma URL.
wh = webhook_create_item()
wh_url = None
if wh:
wh_url = json.loads(wh['config']).get('url') if wh.get('config') else None
# Monday devuelve la URL en config solo a veces; si no, la sabemos:
wh_url = wh_url or 'https://frasa.consultoria-as.com/api/webhooks/monday'
borrar_webhook(wh['id'])
print(f"\nWebhook create_item desregistrado (id {wh['id']}); se recreará al final → {wh_url}")
importados, errores = 0, 0
try:
for r in a_importar:
try:
item_id = crear_lead(cols, r)
importados += 1
print(f"OK {importados}/{len(a_importar)} {r['nombre'][:45]} → {item_id}")
except Exception as e:
errores += 1
print(f"ERROR {r['nombre'][:45]}: {e}")
time.sleep(0.4)
finally:
if wh_url:
nuevo_id = crear_webhook(wh_url)
print(f"\nWebhook create_item recreado (id {nuevo_id})")
print(f"\nImportados: {importados} · errores: {errores}")
if __name__ == '__main__':
main()

View File

@@ -230,7 +230,7 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 }) expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 }) expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 }) expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 })
expect(r.leads.perfil.porRangoSalarial).toEqual({ '20-30 mil': 1 }) expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
// Dimensiones sin ningún dato van como objeto vacío // Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({}) expect(r.leads.perfil.porModelo).toEqual({})
expect(r.leads.perfil.porTipoEmpleo).toEqual({}) expect(r.leads.perfil.porTipoEmpleo).toEqual({})
@@ -239,6 +239,35 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porLoQuierePara).toEqual({}) expect(r.leads.perfil.porLoQuierePara).toEqual({})
}) })
it('perfil: rango salarial de texto libre se agrupa en buckets por moneda', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
lead('9', { rangoSalarial: '' }), // vacío: se salta
],
},
AHORA,
)
expect(r.leads.perfil.porRangoSalarial).toEqual({
'Hasta $30k MXN': 1,
'$30k–$60k MXN': 1,
'$60k–$100k MXN': 1,
'Más de $100k MXN': 1,
'Hasta $3k USD': 1,
'$3k–$5k USD': 1,
'Más de $5k USD': 1,
})
})
it('perfil: buckets de edad y salto de edad null', () => { it('perfil: buckets de edad y salto de edad null', () => {
const r = construirResumen( const r = construirResumen(
{ {

View File

@@ -111,6 +111,44 @@ function bucketEdad(edad: number): string {
return '55+' return '55+'
} }
/**
* Convierte el rango salarial de texto libre ('$80k', '4,500 DLLS', '50-80
* MIL', '100,000.00-120,000.00') a un bucket graficable por moneda; los rangos
* se promedian y K/MIL multiplica miles. null = sin monto interpretable
* ('NO QUISO DECIR', '?', vacío) → se salta de la gráfica.
*/
export function bucketSalario(texto: string): string | null {
const t = texto.toUpperCase()
const tokens = [...t.matchAll(/\d[\d.,]*/g)].map((m) => m[0])
if (tokens.length === 0) return null
const montos = tokens
.map((tok) => {
// separador seguido de 3 dígitos = miles; el resto se trata como decimal
let s = tok
let prev = ''
while (prev !== s) {
prev = s
s = s.replace(/(\d),(\d{3})(?!\d)/g, '$1$2')
}
const n = Number.parseFloat(s.replace(',', '.'))
return Number.isFinite(n) ? n : null
})
.filter((n): n is number => n !== null)
if (montos.length === 0) return null
const enMiles = /\d\s*(K|MIL)\b/.test(t) && montos.every((n) => n < 1000)
const ajustados = enMiles ? montos.map((n) => n * 1000) : montos
const monto = ajustados.reduce((a, b) => a + b, 0) / ajustados.length
if (/DLLS|DLS|USD/.test(t)) {
if (monto < 3000) return 'Hasta $3k USD'
if (monto < 5000) return '$3k–$5k USD'
return 'Más de $5k USD'
}
if (monto < 30000) return 'Hasta $30k MXN'
if (monto < 60000) return '$30k–$60k MXN'
if (monto < 100000) return '$60k–$100k MXN'
return 'Más de $100k MXN'
}
/** Inicio del día local de `ahora` (para "nuevos hoy") */ /** Inicio del día local de `ahora` (para "nuevos hoy") */
function inicioDelDia(d: Date): Date { function inicioDelDia(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()) return new Date(d.getFullYear(), d.getMonth(), d.getDate())
@@ -195,7 +233,11 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')), porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')), porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')), porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
porRangoSalarial: contarPorNoVacios(datos.leads.map((l) => l.rangoSalarial ?? '')), porRangoSalarial: contarPorNoVacios(
datos.leads
.map((l) => bucketSalario(l.rangoSalarial ?? ''))
.filter((b): b is string => b !== null),
),
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')), porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')), porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
porEdad: contarPorNoVacios( porEdad: contarPorNoVacios(