feat(funnel): leads entran como Afluencia al funnel de Oportunidades

- Captura rápida crea lead + oportunidad en etapa Afluencia (vinculadas
  por nueva tabla LeadOportunidad); también aplica a leads creados
  directo en Monday vía webhook
- Transiciones automáticas: AGENDA CITA → Agendo Cita, descarte →
  Perdido (+ dropdown Motivo No Compra), cotización con mismo teléfono
  ACTUALIZA la oportunidad (no duplica) → Cotizador, alta de cliente
  con leadMondayId → Aparto (dedup previo para no duplicar Apartado)
- Columna Desarrollo agregada al board Oportunidades (color_mm6sa20t)
- Migración lead_oportunidad aplicada con migrate diff + resolve (la DB
  tiene drift histórico por db push; migrate dev pedía reset)
- Tests: 322 verdes; smoke en vivo verificado y limpiado
This commit is contained in:
2026-09-01 20:10:51 +00:00
parent ead7a71025
commit 98fedbd8d3
14 changed files with 854 additions and 53 deletions

View File

@@ -1,6 +1,7 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
procesarAlertasInventario,
@@ -72,7 +73,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
let mondayItemId: string | null = null
try {
mondayItemId = await mondayService.crearOportunidad({
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
@@ -83,7 +84,17 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
})
}
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
// oportunidad (etapa 'Cotizador') en vez de crear una segunda.
const opExistente =
body.clienteTelefono !== undefined ? await funnelService.oportunidadPorTelefono(body.clienteTelefono) : null
if (opExistente !== null) {
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
mondayItemId = opExistente
} else {
mondayItemId = await mondayService.crearOportunidad(datosOportunidad)
}
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console