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

@@ -5,13 +5,16 @@ import type { MondayService } from './monday.service.js'
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
* ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo
* histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no
* de Monday: es la lista de afluencias que el usuario dio de alta, con su
* estado actual.
* "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
* Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
* la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
* local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
* dio de alta, con su estado actual (cruza contra ambos tableros por id).
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
@@ -34,6 +37,8 @@ export interface LeadMiDia {
estado: string
origen: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
fuente: 'lead' | 'oportunidad'
}
export interface ActividadMiDia {
@@ -98,7 +103,7 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000
@@ -111,6 +116,10 @@ function fechaLocal(base: Date, dias: number): string {
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
}
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
@@ -133,7 +142,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [oportunidades, actividades, apartados] = await Promise.all([
const [leads, oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
@@ -145,8 +155,25 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
// "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no
// terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente
// primero y con su fuente para las acciones del CRM.
const misLeadsLeads: LeadMiDia[] = sinMapeo
? []
: leads
.filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado))
.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
updatedAt: l.updatedAt,
fuente: 'lead',
}))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeads: LeadMiDia[] = mios
const misLeadsOportunidades: LeadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
@@ -156,7 +183,11 @@ export async function resumenMiDia(
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad',
}))
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
)
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
@@ -166,19 +197,21 @@ export async function resumenMiDia(
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday (las
// capturas nuevas apuntan a Oportunidades; las viejas — del tablero Leads —
// ya no cruzan y quedan con estado/origen ''), más reciente primero
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday,
// cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a
// Leads; las de Alta Afluencia, a Oportunidades), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => {
const lead = leadsPorId.get(c.leadId)
const op = oportunidadesPorId.get(c.leadId)
return {
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: op?.etapa ?? '',
origen: op?.origen ?? '',
estado: lead?.estado ?? op?.etapa ?? '',
origen: lead?.origen ?? op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
@@ -222,8 +255,12 @@ export async function resumenMiDia(
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
}))
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const conteos = {
leadsNuevosSemana: mios.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length,
leadsNuevosSemana:
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length,
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
}
@@ -240,9 +277,23 @@ export async function resumenMiDia(
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
// Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor
// (etapa Afluencia) — lo que el round-robin aún no reparte
const sinAsignar =
leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO')
.length +
oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
// Leads y oportunidades comparten el conteo por vendedor (misma persona Monday)
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
@@ -252,8 +303,7 @@ export async function resumenMiDia(
}
}
resumen.equipo = {
// Afluencias recién llegadas que el round-robin aún no reparte
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
leadsNuevosSinAsignar: sinAsignar,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,