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:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -5,12 +5,15 @@ 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).
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday:
* es la lista de leads que el usuario dio de alta, con su estado actual.
* 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.
*/
// 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']
// Estados del board Apartado que cierran el pipeline Enkontrol
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
@@ -76,9 +79,8 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
misOportunidades: { porEtapa: Record<string, number> } | null
notaOportunidades: string | null
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
misOportunidades: { porEtapa: Record<string, number> }
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -96,7 +98,7 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000
@@ -109,8 +111,8 @@ 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())
}
export function esEstadoFinalApartado(estado: string): boolean {
@@ -131,8 +133,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [leads, actividades, apartados] = await Promise.all([
monday.getLeads(),
const [oportunidades, actividades, apartados] = await Promise.all([
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
])
@@ -143,31 +145,40 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
const mios = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeads: LeadMiDia[] = mios
.filter((l) => !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,
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
}))
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday
// (si ya no existe en el board, estado y origen van ''), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}
for (const o of mios) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
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
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: lead?.estado ?? '',
origen: lead?.origen ?? '',
estado: op?.etapa ?? '',
origen: op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
@@ -224,27 +235,25 @@ export async function resumenMiDia(
misClientes,
misActividadesHoy,
apartadosPorVencer,
// El tablero Oportunidades no tiene columna people de asesor: no se puede
// filtrar "mío"; se omite con nota explícita
misOportunidades: null,
notaOportunidades: 'El tablero Oportunidades no tiene columna de asesor (people); no se puede filtrar por vendedor',
misOportunidades: { porEtapa },
conteos,
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado))
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length,
// Afluencias recién llegadas que el round-robin aún no reparte
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,