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

@@ -1,12 +1,13 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
* afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday),
* unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera),
* afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia
* vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma
* local) y actividades abiertas (Monday).
* Cache en memoria de 60 s para no martillar la API de Monday.
*/
@@ -76,8 +77,10 @@ export interface ResumenReportes {
/** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen {
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
// se alimentan de la MISMA fuente: el tablero Oportunidades
// La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads
// (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta
// Afluencia), que es también la fuente de la sección `oportunidades`
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
@@ -164,8 +167,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const creadosEn = (desde: Date): number =>
datos.oportunidades.filter((o) => {
const t = Date.parse(o.createdAt)
datos.leads.filter((l) => {
const t = Date.parse(l.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
@@ -221,14 +224,14 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
return {
generadoEn: ahora.toISOString(),
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
leads: {
total: datos.oportunidades.length,
total: datos.leads.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
@@ -277,7 +280,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
@@ -313,13 +316,14 @@ export class ReportesService {
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}