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

@@ -230,7 +230,7 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 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
expect(r.leads.perfil.porModelo).toEqual({})
expect(r.leads.perfil.porTipoEmpleo).toEqual({})
@@ -239,6 +239,35 @@ describe('construirResumen', () => {
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', () => {
const r = construirResumen(
{

View File

@@ -111,6 +111,44 @@ function bucketEdad(edad: number): string {
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") */
function inicioDelDia(d: Date): Date {
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 ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
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 ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
porEdad: contarPorNoVacios(