feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades

- POST /api/leads: captura ligera al tablero Leads (sin oportunidad),
  self-assign a Vendedor o round-robin; tambien cubre la captura manual
  desde la UI de Monday (webhook create_item re-registrado).
- POST /api/afluencias (nuevo): la forma completa sigue cayendo en
  Oportunidades con todo el perfil (etapa Afluencia, columna Asesor).
- Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades
  con insignia de origen, acciones con parametro fuente, reportes:
  leads desde Leads, perfilamiento desde Oportunidades, funnel
  Leads->Afluencia->...->Ganado.
This commit is contained in:
2026-09-04 06:09:38 +00:00
parent 772fdef09f
commit 135e3e6de5
21 changed files with 1053 additions and 326 deletions

View File

@@ -115,19 +115,19 @@ describe('unidadesEnEtapa', () => {
})
describe('datosFunnel', () => {
it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
it('ordena Leads → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado', () => {
const funnel = datosFunnel(resumen())
expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
expect(funnel.map((e) => e.valor)).toEqual([40, 8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
})
it('tolera etiquetas con otra capitalización', () => {
const r = resumen()
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
const funnel = datosFunnel(r)
expect(funnel[0]?.valor).toBe(3)
expect(funnel[3]?.valor).toBe(4)
expect(funnel[4]?.valor).toBe(3)
expect(funnel[1]?.valor).toBe(3)
expect(funnel[4]?.valor).toBe(4)
expect(funnel[5]?.valor).toBe(3)
})
})

View File

@@ -104,9 +104,9 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
}
/**
* Datos del funnel comercial, todo desde el tablero Oportunidades (la
* afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita
* → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado
* Datos del funnel comercial: Leads (total del tablero Leads, captura ligera)
* → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (las etapas,
* del tablero Oportunidades vía porEtapa). "Aparto o más" es acumulado
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
*/
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
@@ -117,6 +117,7 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
.reduce((acc, [, v]) => acc + v, 0)
const apartoOMas = conteo('APARTO') + conteo('GANADO')
return [
{ etapa: 'Leads', valor: resumen.leads.total },
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },