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

@@ -1,22 +1,35 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
import { funnelService, type FunnelService } from '../services/funnel.service.js'
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
* CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor.
* Captura y acciones del CRM ligero sobre el tablero Oportunidades: la
* afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo
* el perfil). El tablero Leads queda como archivo histórico: solo se consulta
* en la búsqueda dual del picker del alta de clientes.
* Los labels de estado/origen son los reales del board (verificados contra
* Monday); la whitelist evita escribir labels que el status no tenga dados de alta.
* Monday); la whitelist evita escribir labels que el status no tenga dados de
* alta, y cada valor de la whitelist se traduce al label real del board.
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' del board Oportunidades
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
@@ -52,7 +65,7 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(),
})
// ---------- Búsqueda de leads (prellenado del alta de clientes) ----------
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
@@ -63,70 +76,109 @@ const sinAcentos = (s: string): string =>
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
}
/**
* Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos),
* ordena por actualización reciente y limita a 15 resultados.
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena
* por actualización reciente y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] {
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
return leads
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
const candidatos = [
...leads.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' as const,
})),
...oportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad' as const,
})),
]
return candidatos
.filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead = { id: string; oportunidadId: string | null } | { duplicado: LeadMonday }
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
/**
* Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* El origen se valida contra los labels reales de la columna del board.
* Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia';
* un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó).
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* 'Desarrollo') se validan contra los labels reales del board.
* Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
* dispara en la creación); un fallo ahí solo se loguea.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
@@ -138,98 +190,67 @@ export async function crearLead(
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
let oportunidadId: string | null = null
if (funnel !== undefined) {
try {
oportunidadId = await funnel.asegurarOportunidadAfluencia({
id,
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err)
}
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
}
return { id, oportunidadId }
return { id, oportunidadId: id }
}
/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
funnel?: Pick<FunnelService, 'moverOportunidadDeLead'>,
): Promise<void> {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
}
// La oportunidad del lead avanza a 'Agendo Cita'; un fallo no rompe el cambio
if (estado === 'AGENDA CITA' && funnel !== undefined) {
try {
await funnel.moverOportunidadDeLead(itemId, 'Agendo Cita')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Agendo Cita' la oportunidad del lead ${itemId}:`, err)
}
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
}
/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */
/**
* Estado 'Perdido' + dropdown Motivo No Compra (label ya sembrado; si no
* existiera se omite) + registro en el catálogo Motivos No Compra. El
* comentario opcional va a la columna long_text COMENTARIOS.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
funnel?: Pick<FunnelService, 'oportunidadDeLead'>,
): Promise<string> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const motivoItemId = await monday.crearMotivoNoCompra({
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
// La oportunidad del lead pasa a 'Perdido' y hereda el motivo en su dropdown
// (solo si el label existe en la columna). Fallos → solo log.
if (funnel !== undefined) {
try {
const oportunidadId = await funnel.oportunidadDeLead(itemId)
if (oportunidadId !== null) {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err)
}
}
return motivoItemId
}
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body, funnelService)
const resultado = await crearLead(mondayService, body)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
// (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
// respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) {
try {
await prisma.leadCaptura.create({
@@ -257,10 +279,22 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
// Asignación: si el capturista tiene su usuario Monday vinculado, el lead
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar para
// que el webhook create_item no lo reparta por round-robin mientras Monday
// propaga la escritura (si la asignación falla, se apaga y cae al reparto).
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
// Asignación: si el capturista tiene su usuario Monday vinculado, la
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
// prende ANTES de asignar para que el webhook create_item no la reparta por
// round-robin mientras Monday propaga la escritura (si la asignación falla,
// se apaga y cae al reparto).
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
if (req.usuarioToken?.sub) {
try {
@@ -280,17 +314,17 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el lead ${resultado.id} al capturista:`, err)
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion })
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, funnelService)
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
@@ -298,26 +332,18 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15.
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
const encontrados = buscarLeads(await mondayService.getLeads(), q)
res.json(
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}