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,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
actividadEconomicaLabel,
checkboxParaTipoDocumento,
@@ -6,6 +7,28 @@ import {
rangoEdadLabel,
} from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
update: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
it('mapea los tipos con checkbox al título de la columna', () => {
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
@@ -92,3 +115,52 @@ describe('crearClienteSchema', () => {
).toThrow()
})
})
describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => {
// Import diferido para que los vi.mock de arriba apliquen al controller
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
mondayMock.crearContacto.mockResolvedValue('cont-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
})
it('sin leadMondayId no toca el funnel', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled()
})
it('un fallo del funnel no rompe el alta (201 igual)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
})
})