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

@@ -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(