refactor: las afluencias viven en Oportunidades (no en Leads)
- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia) con todo el perfil (desarrollo, modelo, estado civil, credito, etc.) en las columnas del board; dedup por telefono contra Oportunidades; asignacion al capturista via columna Asesor o round-robin del equipo. - Round-robin, escalado e inactividad operan sobre Oportunidades (solo etapa Afluencia sin asesor entra al reparto). - Acciones del vendedor (contactado/cita/descartar) escriben directo en la oportunidad; descarte = Perdido + Motivo No Compra. - Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en Afluencia; misOportunidades.porEtapa ya es real (columna Asesor). - El picker del alta busca en Leads (historico) Y Oportunidades, con insignia de origen; moverAAparto tolera ambos ids. - Lectura de status con value null = vacio (quirk del default Monday). - Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import type { 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: leads (Monday), oportunidades (Monday), unidades
|
||||
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
|
||||
* 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).
|
||||
* Cache en memoria de 60 s para no martillar la API de Monday.
|
||||
*/
|
||||
|
||||
@@ -75,7 +76,8 @@ export interface ResumenReportes {
|
||||
|
||||
/** Datos crudos que necesita el agregador (inyectables en tests) */
|
||||
export interface DatosResumen {
|
||||
leads: LeadMonday[]
|
||||
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
|
||||
// se alimentan de la MISMA fuente: el tablero Oportunidades
|
||||
oportunidades: OportunidadMonday[]
|
||||
unidades: Unidad[]
|
||||
planes: PlanPago[] // dominio de cobranza (con pagos)
|
||||
@@ -162,8 +164,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.leads.filter((l) => {
|
||||
const t = Date.parse(l.createdAt)
|
||||
datos.oportunidades.filter((o) => {
|
||||
const t = Date.parse(o.createdAt)
|
||||
return !Number.isNaN(t) && t >= desde.getTime()
|
||||
}).length
|
||||
|
||||
@@ -219,29 +221,31 @@ 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')
|
||||
leads: {
|
||||
total: datos.leads.length,
|
||||
total: datos.oportunidades.length,
|
||||
nuevosHoy: creadosEn(hoy),
|
||||
nuevosSemana: creadosEn(hace7dias),
|
||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
|
||||
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
|
||||
perfil: {
|
||||
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
|
||||
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
|
||||
porRangoSalarial: contarPorNoVacios(
|
||||
datos.leads
|
||||
.map((l) => bucketSalario(l.rangoSalarial ?? ''))
|
||||
datos.oportunidades
|
||||
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
||||
.filter((b): b is string => b !== null),
|
||||
),
|
||||
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
|
||||
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
|
||||
porEdad: contarPorNoVacios(
|
||||
datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)),
|
||||
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -273,7 +277,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
||||
|
||||
// ---------- Servicio con cache ----------
|
||||
|
||||
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
|
||||
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
|
||||
|
||||
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
|
||||
export type CargadorPlanes = () => Promise<PlanPago[]>
|
||||
@@ -309,14 +313,13 @@ 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 [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||
this.monday.getLeads(),
|
||||
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||
this.monday.getOportunidades(),
|
||||
this.monday.getUnidades(),
|
||||
this.monday.getActividadesAbiertas(),
|
||||
this.cargarPlanes(),
|
||||
])
|
||||
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||
return resumen
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user