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:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user